Skip to content

Commit 76bff56

Browse files
authored
feat(template): add new schema based configuration infrastructures (#6567)
* feat(template): add new config schema Signed-off-by: Nixieboluo <me@sagirii.me> * feat(shared): add config loader utils Signed-off-by: Nixieboluo <me@sagirii.me> * feat(template): try loading config file in instrumentation hook Signed-off-by: Nixieboluo <me@sagirii.me> * docs(shared): config loader docs Signed-off-by: Nixieboluo <me@sagirii.me> * feat(shared): client app config utils Signed-off-by: Nixieboluo <me@sagirii.me> * feat(template): setup client app config Signed-off-by: Nixieboluo <me@sagirii.me> * fix(shared): ensure client app config is fetched before loading other parts Signed-off-by: Nixieboluo <me@sagirii.me> * refactor(template): remove next public env vars Signed-off-by: Nixieboluo <me@sagirii.me> * feat(template): remove env var usages Signed-off-by: Nixieboluo <me@sagirii.me> * feat(template): remove system config (slide data) usages Signed-off-by: Nixieboluo <me@sagirii.me> * feat(template): remove system env store usage Signed-off-by: Nixieboluo <me@sagirii.me> * refactor(template): remove sidebar store Signed-off-by: Nixieboluo <me@sagirii.me> * feat(template): provide schema enforced config example Signed-off-by: Nixieboluo <me@sagirii.me> * fix(template): ignore log rules in instrumentation hook Signed-off-by: Nixieboluo <me@sagirii.me> --------- Signed-off-by: Nixieboluo <me@sagirii.me>
1 parent a4597bf commit 76bff56

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1168
-413
lines changed

frontend/packages/shared/package.json

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
"name": "@sealos/shared",
33
"version": "1.0.0",
44
"description": "Shared utilities, types, and components for Sealos frontend applications",
5+
"license": "ISC",
56
"private": true,
67
"main": "./src/index.ts",
78
"types": "./src/index.ts",
89
"exports": {
910
".": "./src/index.ts",
1011
"./chakra": "./src/components/chakra/index.ts",
1112
"./shadcn": "./src/components/shadcn/index.ts",
12-
"./components/shadcn/styles.css": "./src/components/shadcn/styles.css"
13+
"./components/shadcn/styles.css": "./src/components/shadcn/styles.css",
14+
"./server/config": "./src-server/config/index.ts"
1315
},
1416
"typesVersions": {
1517
"*": {
@@ -18,55 +20,98 @@
1820
],
1921
"shadcn": [
2022
"src/components/shadcn/index.ts"
23+
],
24+
"server/config": [
25+
"src-server/config/index.ts"
2126
]
2227
}
2328
},
2429
"scripts": {
2530
"build": "exit 0",
26-
"lint": "eslint src --ext .ts,.tsx",
31+
"lint": "eslint src src-server --ext .ts,.tsx",
2732
"typecheck": "tsc --noEmit"
2833
},
29-
"keywords": [
30-
"sealos",
31-
"shared"
32-
],
33-
"author": "",
34-
"license": "ISC",
3534
"peerDependencies": {
3635
"@chakra-ui/icons": "^2",
3736
"@chakra-ui/react": "^2",
37+
"@tanstack/react-query": "^4",
3838
"immer": "^10",
39+
"js-yaml": "^4.1.0",
3940
"lucide-react": ">=0.460.0",
41+
"picocolors": "^1.1.1",
4042
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
4143
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc",
4244
"sealos-desktop-sdk": "workspace:*",
43-
"zustand": "^4 || ^5",
44-
"zod": "^3 || ^4"
45+
"zod": "^3 || ^4",
46+
"zustand": "^4 || ^5"
4547
},
4648
"peerDependenciesMeta": {
4749
"@chakra-ui/icons": {
48-
"optional": true
50+
"optional": true,
51+
"description": "Optional for frontend code (Chakra UI components)"
4952
},
5053
"@chakra-ui/react": {
51-
"optional": true
54+
"optional": true,
55+
"description": "Optional for frontend code (Chakra UI components)"
56+
},
57+
"js-yaml": {
58+
"optional": false,
59+
"description": "Required for backend code (./server export)"
60+
},
61+
"picocolors": {
62+
"optional": false,
63+
"description": "Required for backend code (./server export, error formatting)"
64+
},
65+
"immer": {
66+
"optional": false,
67+
"description": "Required for frontend code (state management)"
68+
},
69+
"lucide-react": {
70+
"optional": false,
71+
"description": "Required for frontend code (icons)"
72+
},
73+
"react": {
74+
"optional": false,
75+
"description": "Required for frontend code"
76+
},
77+
"react-dom": {
78+
"optional": false,
79+
"description": "Required for frontend code"
80+
},
81+
"sealos-desktop-sdk": {
82+
"optional": false,
83+
"description": "Required for frontend code"
84+
},
85+
"zustand": {
86+
"optional": false,
87+
"description": "Required for frontend code (state management)"
88+
},
89+
"zod": {
90+
"optional": false,
91+
"description": "Required for frontend code (schema validation)"
5292
}
5393
},
5494
"devDependencies": {
5595
"@chakra-ui/icons": "^2.1.1",
5696
"@chakra-ui/react": "^2.8.1",
97+
"@tanstack/react-query": "^4.36.1",
98+
"@types/js-yaml": "^4.0.9",
99+
"@types/node": "^20.7.1",
57100
"@types/react": "18.3.27",
58101
"@types/react-dom": "18.3.7",
59102
"@typescript-eslint/eslint-plugin": "^6.21.0",
60103
"@typescript-eslint/parser": "^6.21.0",
61104
"eslint": "^8.57.0",
62105
"eslint-plugin-import": "^2.29.1",
106+
"immer": "^10.2.0",
107+
"js-yaml": "^4.1.0",
63108
"lucide-react": "^0.476.0",
109+
"picocolors": "^1.1.1",
64110
"react": "18.3.1",
65111
"react-dom": "18.3.1",
66112
"sealos-desktop-sdk": "workspace:*",
67113
"typescript": "^5.9.3",
68-
"zustand": "^4.5.6",
69-
"immer": "^10.2.0",
70-
"zod": "^3.22.4"
114+
"zod": "^4.3.5",
115+
"zustand": "^4.5.6"
71116
}
72117
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @sealos/shared/server
2+
3+
Server-side utilities for Sealos applications.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Config module
2+
3+
## Usage
4+
5+
```typescript
6+
import { readConfig, prettyPrintErrors } from '@sealos/shared/server/config';
7+
import type { ConfigResult } from '@sealos/shared/server/config';
8+
9+
// For example, configText can be loaded with fs module
10+
11+
// Defaults to YAML parsing (async)
12+
const result = await readConfig(configText, AppConfigSchema);
13+
14+
// Or use custom parser
15+
const result = await readConfig(configText, AppConfigSchema, JSON.parse);
16+
17+
if (result.error) {
18+
console.error(prettyPrintErrors(result.error.details));
19+
process.exit(1);
20+
}
21+
22+
console.log(result.data);
23+
```
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { ZodError } from 'zod';
2+
import * as pc from 'picocolors';
3+
4+
/**
5+
* Options for pretty printing errors.
6+
*/
7+
export interface PrettyPrintOptions {
8+
/** Include error code in output */
9+
showCode?: boolean;
10+
/** Indentation string */
11+
indent?: string;
12+
}
13+
14+
/**
15+
* Create a horizontal separator line.
16+
*/
17+
function separator(char: string = '─', length: number = 60): string {
18+
return pc.dim(char.repeat(length));
19+
}
20+
21+
/**
22+
* Calculate the display width of a number with dot and space (without ANSI color codes).
23+
* Format: "1. " = 3 chars, "10. " = 4 chars
24+
*/
25+
function numberWidth(num: number): number {
26+
return String(num).length + 2; // +1 for dot, +1 for space (e.g., "1. " = 3 chars)
27+
}
28+
29+
/**
30+
* Pretty print Zod validation errors in a human-readable format with colors.
31+
*/
32+
export function prettyPrintErrors(
33+
error: ZodError | Error,
34+
options: PrettyPrintOptions = {}
35+
): string {
36+
const { showCode = true, indent = ' ' } = options;
37+
38+
// Handle non-Zod errors
39+
if (!(error instanceof ZodError)) {
40+
return `${pc.red('✗')} ${pc.bold('Error')}: ${error.message}${
41+
error.stack ? '\n' + error.stack : ''
42+
}`;
43+
}
44+
45+
const lines: string[] = [];
46+
47+
// Header with separator
48+
lines.push(pc.red('✗ Configuration validation errors'));
49+
lines.push(separator());
50+
lines.push('');
51+
52+
// Calculate max issue number width for alignment (with at least one separating space)
53+
const maxIssueNum = error.issues.length;
54+
const maxIssueNumWidth = numberWidth(maxIssueNum) + 1;
55+
56+
error.issues.forEach((issue, index) => {
57+
const path = issue.path.length > 0 ? issue.path.join(pc.dim('.')) : pc.dim('<root>');
58+
const code = showCode ? pc.gray(` [${issue.code}]`) : '';
59+
const issueNum = pc.yellow(`${index + 1}. `);
60+
const issueNumWidth = numberWidth(index + 1);
61+
const padding = ' '.repeat(maxIssueNumWidth - issueNumWidth);
62+
const fieldPrefix = `${issueNum}${padding}`;
63+
const spacePrefix = ' '.repeat(maxIssueNumWidth);
64+
65+
lines.push(`${fieldPrefix}${pc.bold('Field')}: ${pc.cyan(path)}${code}`);
66+
lines.push(`${spacePrefix}${pc.bold('Message')}: ${pc.red(issue.message)}`);
67+
68+
// Add received value if available
69+
if ('received' in issue && issue.received !== undefined) {
70+
const receivedStr = JSON.stringify(issue.received);
71+
lines.push(`${spacePrefix}${pc.bold('Received')}: ${pc.red(receivedStr)}`);
72+
}
73+
74+
// Add expected value if available
75+
if ('expected' in issue && issue.expected !== undefined) {
76+
const expectedStr = JSON.stringify(issue.expected);
77+
lines.push(`${spacePrefix}${pc.bold('Expected')}: ${pc.green(expectedStr)}`);
78+
}
79+
80+
// Add valid values if available
81+
if ('values' in issue && issue.values !== undefined) {
82+
const valuesStr = JSON.stringify(issue.values);
83+
lines.push(`${spacePrefix}${pc.bold('Valid values')}: ${pc.green(valuesStr)}`);
84+
}
85+
86+
// Add union errors if available
87+
if (issue.code === 'invalid_union' && 'unionErrors' in issue) {
88+
const unionErrors = issue.unionErrors as ZodError[];
89+
lines.push(`${spacePrefix}${pc.bold('Union errors')}:`);
90+
unionErrors.forEach((unionErr: ZodError, uIdx: number) => {
91+
lines.push(`${spacePrefix}${indent}${pc.yellow(`Option ${uIdx + 1}`)}:`);
92+
unionErr.issues.forEach((subIssue) => {
93+
const subPath =
94+
subIssue.path.length > 0 ? subIssue.path.join(pc.dim('.')) : pc.dim('<root>');
95+
lines.push(
96+
`${spacePrefix}${indent}${indent}${pc.dim('─')} ${subPath}: ${pc.red(subIssue.message)}`
97+
);
98+
});
99+
});
100+
}
101+
102+
// Add separator between issues (except last one)
103+
if (index < error.issues.length - 1) {
104+
lines.push('');
105+
lines.push(separator('·', 40));
106+
lines.push('');
107+
}
108+
});
109+
110+
// Footer with summary
111+
lines.push('');
112+
lines.push(separator());
113+
lines.push(`${pc.red('✗')} ${pc.bold('Total errors')}: ${pc.red(String(error.issues.length))}`);
114+
115+
return lines.join('\n');
116+
}
117+
118+
/**
119+
* Format Zod error for logging (compact one-line format).
120+
*/
121+
export function formatErrorCompact(error: ZodError | Error): string {
122+
if (!(error instanceof ZodError)) {
123+
return `Error: ${error.message}`;
124+
}
125+
126+
const issues = error.issues.map((issue) => {
127+
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
128+
return `${path}: ${issue.message}`;
129+
});
130+
131+
return `Validation failed: ${issues.join('; ')}`;
132+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Configuration reading and validation utilities.
3+
*/
4+
5+
export { readConfig } from './reader';
6+
export { prettyPrintErrors, formatErrorCompact, type PrettyPrintOptions } from './error-formatter';
7+
export { mountToGlobalThis } from './utils';
8+
export type { ConfigResult, ConfigSuccess, ConfigError } from './types';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { z } from 'zod';
2+
import type { ConfigResult } from './types';
3+
4+
/**
5+
* Read and validate configuration from a text string.
6+
* Defaults to YAML parsing if parser is not provided.
7+
*
8+
* @returns frozen readonly config.
9+
*/
10+
export async function readConfig<T extends z.ZodType>(
11+
text: string,
12+
schema: T,
13+
parser?: (text: string) => unknown
14+
): Promise<ConfigResult<z.output<ReturnType<T['readonly']>>>> {
15+
try {
16+
let parsed: unknown;
17+
18+
if (parser) {
19+
parsed = parser(text);
20+
} else {
21+
// Default to YAML parsing
22+
const yaml = await import('js-yaml');
23+
parsed = yaml.load(text);
24+
}
25+
26+
// Use readonly schema to ensure deep readonly types and runtime immutability
27+
const readonlySchema = schema.readonly();
28+
const result = readonlySchema.safeParse(parsed);
29+
30+
if (!result.success) {
31+
return {
32+
error: {
33+
message: 'Configuration validation failed',
34+
details: result.error
35+
}
36+
};
37+
}
38+
39+
const config = result.data as z.output<ReturnType<T['readonly']>>;
40+
41+
return {
42+
data: config
43+
};
44+
} catch (err) {
45+
const error = err instanceof Error ? err : new Error(String(err));
46+
return {
47+
error: {
48+
message: `Failed to parse configuration: ${error.message}`,
49+
details: error
50+
}
51+
};
52+
}
53+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ZodError } from 'zod';
2+
3+
/**
4+
* Result type for successful config parsing.
5+
*/
6+
export type ConfigSuccess<T> = {
7+
data: T;
8+
error?: never;
9+
};
10+
11+
/**
12+
* Result type for failed config parsing.
13+
*/
14+
export type ConfigError = {
15+
data?: never;
16+
error: {
17+
message: string;
18+
details: ZodError | Error;
19+
};
20+
};
21+
22+
/**
23+
* Result type for config parsing operations.
24+
*/
25+
export type ConfigResult<T> = ConfigSuccess<T> | ConfigError;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Safely mount a value to globalThis.
3+
* The value should already be readonly (from Zod's readonly()).
4+
*
5+
* @param key - Key to mount on globalThis
6+
* @param value - Value to mount (should be readonly from Zod)
7+
*/
8+
export function mountToGlobalThis<T>(key: string, value: T): void {
9+
(globalThis as Record<string, unknown>)[key] = value;
10+
}

0 commit comments

Comments
 (0)