Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit 94c5c8a

Browse files
authored
feat(cli): ZenStack proxy (#597)
* feat(cli): ZenStack proxy * add the missing change * resolve comments * feat(cli): add alias for proxy command and improve console messages * fix(cli): update output option description for ZenStack proxy command
1 parent 077f03f commit 94c5c8a

7 files changed

Lines changed: 314 additions & 59 deletions

File tree

packages/cli/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,30 +38,37 @@
3838
"dependencies": {
3939
"@zenstackhq/common-helpers": "workspace:*",
4040
"@zenstackhq/language": "workspace:*",
41+
"@zenstackhq/orm": "workspace:*",
4142
"@zenstackhq/sdk": "workspace:*",
43+
"@zenstackhq/server": "workspace:*",
44+
"better-sqlite3": "catalog:",
4245
"chokidar": "^5.0.0",
4346
"colors": "1.4.0",
4447
"commander": "^8.3.0",
48+
"cors": "^2.8.5",
4549
"execa": "^9.6.0",
50+
"express": "^5.0.0",
4651
"jiti": "^2.6.1",
4752
"langium": "catalog:",
4853
"mixpanel": "^0.18.1",
4954
"ora": "^5.4.1",
5055
"package-manager-detector": "^1.3.0",
56+
"pg": "catalog:",
5157
"prisma": "catalog:",
5258
"semver": "^7.7.2",
5359
"ts-pattern": "catalog:"
5460
},
5561
"devDependencies": {
5662
"@types/better-sqlite3": "catalog:",
63+
"@types/cors": "^2.8.19",
64+
"@types/express": "^5.0.0",
65+
"@types/pg": "^8.16.0",
5766
"@types/semver": "^7.7.0",
5867
"@types/tmp": "catalog:",
5968
"@zenstackhq/eslint-config": "workspace:*",
60-
"@zenstackhq/orm": "workspace:*",
6169
"@zenstackhq/testtools": "workspace:*",
6270
"@zenstackhq/typescript-config": "workspace:*",
6371
"@zenstackhq/vitest-config": "workspace:*",
64-
"better-sqlite3": "catalog:",
6572
"tmp": "catalog:"
6673
},
6774
"engines": {

packages/cli/src/actions/action-utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,15 @@ export async function requireDataSourceUrl(schemaFile: string) {
144144
throw new CliError('The schema\'s "datasource" must have a "url" field to use this command.');
145145
}
146146
}
147+
148+
export function getOutputPath(options: { output?: string }, schemaFile: string) {
149+
if (options.output) {
150+
return options.output;
151+
}
152+
const pkgJsonConfig = getPkgJsonConfig(process.cwd());
153+
if (pkgJsonConfig.output) {
154+
return pkgJsonConfig.output;
155+
} else {
156+
return path.dirname(schemaFile);
157+
}
158+
}

packages/cli/src/actions/generate.ts

Lines changed: 42 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { watch } from 'chokidar';
1212
import ora, { type Ora } from 'ora';
1313
import { CliError } from '../cli-error';
1414
import * as corePlugins from '../plugins';
15-
import { getPkgJsonConfig, getSchemaFile, loadSchemaDocument } from './action-utils';
15+
import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils';
1616

1717
type Options = {
1818
schema?: string;
@@ -39,15 +39,16 @@ export async function run(options: Options) {
3939
const schemaExtensions = ZModelLanguageMetaData.fileExtensions;
4040

4141
// Get real models file path (cuz its merged into single document -> we need use cst nodes)
42-
const getRootModelWatchPaths = (model: Model) => new Set<string>(
43-
(
44-
model.declarations.filter(
45-
(v) =>
46-
v.$cstNode?.parent?.element.$type === 'Model' &&
47-
!!v.$cstNode.parent.element.$document?.uri?.fsPath,
48-
) as AbstractDeclaration[]
49-
).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath),
50-
);
42+
const getRootModelWatchPaths = (model: Model) =>
43+
new Set<string>(
44+
(
45+
model.declarations.filter(
46+
(v) =>
47+
v.$cstNode?.parent?.element.$type === 'Model' &&
48+
!!v.$cstNode.parent.element.$document?.uri?.fsPath,
49+
) as AbstractDeclaration[]
50+
).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath),
51+
);
5152

5253
const watchedPaths = getRootModelWatchPaths(model);
5354

@@ -64,40 +65,44 @@ export async function run(options: Options) {
6465
});
6566

6667
// prevent save multiple files and run multiple times
67-
const reGenerateSchema = singleDebounce(async () => {
68-
if (logsEnabled) {
69-
console.log('Got changes, run generation!');
70-
}
68+
const reGenerateSchema = singleDebounce(
69+
async () => {
70+
if (logsEnabled) {
71+
console.log('Got changes, run generation!');
72+
}
7173

72-
try {
73-
const newModel = await pureGenerate(options, true);
74-
const allModelsPaths = getRootModelWatchPaths(newModel);
75-
const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at));
76-
const removeModelPaths = [...watchedPaths].filter((at) => !allModelsPaths.has(at));
74+
try {
75+
const newModel = await pureGenerate(options, true);
76+
const allModelsPaths = getRootModelWatchPaths(newModel);
77+
const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at));
78+
const removeModelPaths = [...watchedPaths].filter((at) => !allModelsPaths.has(at));
7779

78-
if (newModelPaths.length) {
79-
if (logsEnabled) {
80-
const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n');
81-
console.log(`Added file(s) to watch:\n${logPaths}`);
80+
if (newModelPaths.length) {
81+
if (logsEnabled) {
82+
const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n');
83+
console.log(`Added file(s) to watch:\n${logPaths}`);
84+
}
85+
86+
newModelPaths.forEach((at) => watchedPaths.add(at));
87+
watcher.add(newModelPaths);
8288
}
8389

84-
newModelPaths.forEach((at) => watchedPaths.add(at));
85-
watcher.add(newModelPaths);
86-
}
90+
if (removeModelPaths.length) {
91+
if (logsEnabled) {
92+
const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n');
93+
console.log(`Removed file(s) from watch:\n${logPaths}`);
94+
}
8795

88-
if (removeModelPaths.length) {
89-
if (logsEnabled) {
90-
const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n');
91-
console.log(`Removed file(s) from watch:\n${logPaths}`);
96+
removeModelPaths.forEach((at) => watchedPaths.delete(at));
97+
watcher.unwatch(removeModelPaths);
9298
}
93-
94-
removeModelPaths.forEach((at) => watchedPaths.delete(at));
95-
watcher.unwatch(removeModelPaths);
99+
} catch (e) {
100+
console.error(e);
96101
}
97-
} catch (e) {
98-
console.error(e);
99-
}
100-
}, 500, true);
102+
},
103+
500,
104+
true,
105+
);
101106

102107
watcher.on('unlink', (pathAt) => {
103108
if (logsEnabled) {
@@ -148,18 +153,6 @@ Check documentation: https://zenstack.dev/docs/`);
148153
return model;
149154
}
150155

151-
function getOutputPath(options: Options, schemaFile: string) {
152-
if (options.output) {
153-
return options.output;
154-
}
155-
const pkgJsonConfig = getPkgJsonConfig(process.cwd());
156-
if (pkgJsonConfig.output) {
157-
return pkgJsonConfig.output;
158-
} else {
159-
return path.dirname(schemaFile);
160-
}
161-
}
162-
163156
async function runPlugins(schemaFile: string, model: Model, outputPath: string, options: Options) {
164157
const plugins = model.declarations.filter(isPlugin);
165158
const processedPlugins: { cliPlugin: CliPlugin; pluginOptions: Record<string, unknown> }[] = [];

packages/cli/src/actions/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ import { run as info } from './info';
66
import { run as init } from './init';
77
import { run as migrate } from './migrate';
88
import { run as seed } from './seed';
9+
import { run as proxy } from './proxy';
910

10-
export { check, db, format, generate, info, init, migrate, seed };
11+
export { check, db, format, generate, info, init, migrate, seed, proxy };

packages/cli/src/actions/proxy.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { isDataSource } from '@zenstackhq/language/ast';
2+
import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils';
3+
import { CliError } from '../cli-error';
4+
import { ZModelCodeGenerator } from '@zenstackhq/language';
5+
import { getStringLiteral } from '@zenstackhq/language/utils';
6+
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
7+
import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
8+
import SQLite from 'better-sqlite3';
9+
import { Pool } from 'pg';
10+
import path from 'node:path';
11+
import { ZenStackClient, type ClientContract } from '@zenstackhq/orm';
12+
import { RPCApiHandler } from '@zenstackhq/server/api';
13+
import { ZenStackMiddleware } from '@zenstackhq/server/express';
14+
import express from 'express';
15+
import colors from 'colors';
16+
import { createJiti } from 'jiti';
17+
import { getVersion } from '../utils/version-utils';
18+
import cors from 'cors';
19+
20+
type Options = {
21+
output?: string;
22+
schema?: string;
23+
port?: number;
24+
logLevel?: string[];
25+
databaseUrl?: string;
26+
};
27+
28+
export async function run(options: Options) {
29+
const schemaFile = getSchemaFile(options.schema);
30+
console.log(colors.gray(`Loading ZModel schema from: ${schemaFile}`));
31+
32+
let outputPath = getOutputPath(options, schemaFile);
33+
34+
// Ensure outputPath is absolute
35+
if (!path.isAbsolute(outputPath)) {
36+
outputPath = path.resolve(process.cwd(), outputPath);
37+
}
38+
39+
const model = await loadSchemaDocument(schemaFile);
40+
41+
const dataSource = model.declarations.find(isDataSource);
42+
43+
let databaseUrl = options.databaseUrl;
44+
45+
if (!databaseUrl) {
46+
const schemaUrl = dataSource?.fields.find((f) => f.name === 'url')?.value;
47+
48+
if (!schemaUrl) {
49+
throw new CliError(
50+
`The schema's "datasource" does not have a "url" field, please provide it with -d option.`,
51+
);
52+
}
53+
const zModelGenerator = new ZModelCodeGenerator();
54+
const url = zModelGenerator.generate(schemaUrl);
55+
56+
databaseUrl = evaluateUrl(url);
57+
}
58+
59+
const provider = getStringLiteral(dataSource?.fields.find((f) => f.name === 'provider')?.value)!;
60+
61+
const dialect = createDialect(provider, databaseUrl!, outputPath);
62+
63+
const jiti = createJiti(import.meta.url);
64+
65+
const schemaModule = (await jiti.import(path.join(outputPath, 'schema'))) as any;
66+
67+
const allowedLogLevels = ['error', 'query'] as const;
68+
const log = options.logLevel?.filter((level): level is (typeof allowedLogLevels)[number] =>
69+
allowedLogLevels.includes(level as any),
70+
);
71+
72+
const db = new ZenStackClient(schemaModule.schema, {
73+
dialect: dialect,
74+
log: log && log.length > 0 ? log : undefined,
75+
});
76+
77+
// check whether the database is reachable
78+
try {
79+
await db.$connect();
80+
} catch (err) {
81+
throw new CliError(`Failed to connect to the database: ${err instanceof Error ? err.message : String(err)}`);
82+
}
83+
84+
startServer(db, schemaModule.schema, options);
85+
}
86+
87+
function evaluateUrl(value: string): string {
88+
// Create env helper function
89+
const env = (varName: string) => {
90+
const envValue = process.env[varName];
91+
if (!envValue) {
92+
throw new CliError(`Environment variable ${varName} is not set`);
93+
}
94+
return envValue;
95+
};
96+
97+
try {
98+
// Use Function constructor to evaluate the url value
99+
const urlFn = new Function('env', `return ${value}`);
100+
const url = urlFn(env);
101+
return url;
102+
} catch (err) {
103+
if (err instanceof CliError) {
104+
throw err;
105+
}
106+
throw new CliError('Could not evaluate datasource url from schema, you could provide it via -d option.');
107+
}
108+
}
109+
110+
function createDialect(provider: string, databaseUrl: string, outputPath: string) {
111+
switch (provider) {
112+
case 'sqlite': {
113+
let resolvedUrl = databaseUrl.trim();
114+
if (resolvedUrl.startsWith('file:')) {
115+
const filePath = resolvedUrl.substring('file:'.length);
116+
if (!path.isAbsolute(filePath)) {
117+
resolvedUrl = path.join(outputPath, filePath);
118+
}
119+
}
120+
console.log(colors.gray(`Connecting to SQLite database at: ${resolvedUrl}`));
121+
return new SqliteDialect({
122+
database: new SQLite(resolvedUrl),
123+
});
124+
}
125+
case 'postgresql':
126+
console.log(colors.gray(`Connecting to PostgreSQL database at: ${databaseUrl}`));
127+
return new PostgresDialect({
128+
pool: new Pool({
129+
connectionString: databaseUrl,
130+
}),
131+
});
132+
default:
133+
throw new CliError(`Unsupported database provider: ${provider}`);
134+
}
135+
}
136+
137+
function startServer(client: ClientContract<any, any>, schema: any, options: Options) {
138+
const app = express();
139+
app.use(cors());
140+
app.use(express.json({ limit: '5mb' }));
141+
app.use(express.urlencoded({ extended: true, limit: '5mb' }));
142+
143+
app.use(
144+
'/api/model',
145+
ZenStackMiddleware({
146+
apiHandler: new RPCApiHandler({ schema }),
147+
getClient: () => client,
148+
}),
149+
);
150+
151+
app.get('/api/schema', (_req, res: express.Response) => {
152+
res.json({ ...schema, zenstackVersion: getVersion() });
153+
});
154+
155+
const server = app.listen(options.port, () => {
156+
console.log(`ZenStack proxy server is running on port: ${options.port}`);
157+
console.log(`You can visit ZenStack Studio at: ${colors.blue('https://studio.zenstack.dev')}`);
158+
});
159+
160+
// Graceful shutdown
161+
process.on('SIGTERM', async () => {
162+
server.close(() => {
163+
console.log('\nZenStack proxy server closed');
164+
});
165+
166+
await client.$disconnect();
167+
process.exit(0);
168+
});
169+
170+
process.on('SIGINT', async () => {
171+
server.close(() => {
172+
console.log('\nZenStack proxy server closed');
173+
});
174+
await client.$disconnect();
175+
process.exit(0);
176+
});
177+
}

0 commit comments

Comments
 (0)