Production build update (#87)

* docs: Replace Crisp with Discord

* feat: Optimize production build

* feat: Add Dockerfile and docker-compose.yml templates
This commit is contained in:
Arthur 2025-09-22 07:53:35 +03:00 committed by GitHub
parent ab7fd69e8c
commit 162df86c4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 206 additions and 79 deletions

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[team@kottster.io](team@kottster.io).
[team@kottster.app](team@kottster.app).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@ -91,7 +91,7 @@ See [CONTRIBUTING.md](https://github.com/kottster/kottster/blob/main/CONTRIBUTIN
- 💬 [Join our Discord](https://discord.com/invite/Qce9uUqK98)
- 📬 [Contact us](https://kottster.app/contact-us)
- ✉️ [team@kottster.io](mailto:team@kottster.io)
- ✉️ [team@kottster.app](mailto:team@kottster.app)
## License

View File

@ -55,7 +55,7 @@ export default defineConfig({
'script',
{
src: '/docs/js/discord.js',
defer: 'true',
defer: 'true'
}
],
[
@ -107,8 +107,6 @@ export default defineConfig({
],
],
sitemap: {
hostname: 'https://kottster.app',
},

View File

@ -4,13 +4,35 @@ import fs from 'fs/promises';
import { checkTsUsage } from '@kottster/common';
export async function buildServer(): Promise<void> {
const projectDir = process.cwd();
const usingTsc = checkTsUsage();
// Find all api.server.js files in the app/pages directory
const filenameEnding = `.server.${usingTsc ? 'ts' : 'js'}`;
const projectDir = process.cwd();
const pagesDir = path.join(projectDir, 'app/pages');
const apiFiles: string[] = [];
const dataSourcesDir = path.join(projectDir, 'app/_server/data-sources');
// Find all app/pages/<pageKey>/page.json
const pageJsonFiles: string[] = [];
try {
const dirs = await fs.readdir(pagesDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const dirPath = path.join(pagesDir, dir.name);
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file === 'page.json') {
pageJsonFiles.push(path.join(dirPath, file));
}
}
}
}
} catch (error) {
console.warn('Could not read pages directory:', error);
}
// Find all app/pages/<pageKey>/api.server.js or .ts files
const filenameEnding = `.server.${usingTsc ? 'ts' : 'js'}`;
const pageApiFiles: string[] = [];
try {
const dirs = await fs.readdir(pagesDir, { withFileTypes: true });
@ -21,7 +43,7 @@ export async function buildServer(): Promise<void> {
for (const file of files) {
if (file.endsWith(filenameEnding)) {
apiFiles.push(path.join(dirPath, file));
pageApiFiles.push(path.join(dirPath, file));
}
}
}
@ -30,8 +52,26 @@ export async function buildServer(): Promise<void> {
console.warn('Could not read pages directory:', error);
}
// Find all data source files in the app/_server/data-sources directory
const dataSourcesDir = path.join(projectDir, 'app/_server/data-sources');
// Find all app/_server/data-sources/<dataSource>/dataSource.json
const dataSourceJsonFiles: string[] = [];
try {
const dirs = await fs.readdir(dataSourcesDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const dirPath = path.join(dataSourcesDir, dir.name);
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file === 'dataSource.json') {
dataSourceJsonFiles.push(path.join(dirPath, file));
}
}
}
}
} catch (error) {
console.warn('Could not read data sources directory:', error);
}
// Find all app/_server/data-sources/<dataSource>/index.js or .ts files
const dataSourceFiles: string[] = [];
try {
const dirs = await fs.readdir(dataSourcesDir, { withFileTypes: true });
@ -53,7 +93,7 @@ export async function buildServer(): Promise<void> {
const input = {
server: `app/_server/server.${usingTsc ? 'ts' : 'js'}`,
...Object.fromEntries(
apiFiles.map(file => [
pageApiFiles.map(file => [
file.replace(path.join(projectDir, 'app/'), '').replace(filenameEnding, ''),
file
])
@ -63,7 +103,7 @@ export async function buildServer(): Promise<void> {
file.replace(path.join(projectDir, 'app/_server/'), '').replace(`.${usingTsc ? 'ts' : 'js'}`, ''),
file
])
)
),
};
try {
@ -86,7 +126,30 @@ export async function buildServer(): Promise<void> {
'@': '/app'
}
}
})
});
// Copy app/pages/<pageKey>/page.json files
for (const file of pageJsonFiles) {
const destPath = path.join(
projectDir,
'dist/server',
path.relative(path.join(projectDir, 'app'), file)
);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(file, destPath);
}
// Copy app/_server/data-sources/<dataSource>/dataSource.json files
for (const file of dataSourceJsonFiles) {
const destPath = path.join(
projectDir,
'dist/server',
path.relative(path.join(projectDir, 'app/_server'), file)
);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(file, destPath);
}
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error)

View File

@ -45,7 +45,12 @@ export async function startProjectDev(options: Options): Promise<void> {
const serverEnv = {
...process.env,
// Set NODE_ENV=development just in case
NODE_ENV: 'development',
// Kottster app uses it's environment variable to determine the stage,
KOTTSTER_APP_STAGE: 'development',
VITE_KOTTSTER_APP_STAGE: 'development',
DEV_API_SERVER_PORT: devApiServerPortStr,
VITE_DEV_API_SERVER_PORT: devApiServerPortStr,

View File

@ -75,6 +75,8 @@ export class FileCreator {
// Create files
this.createFileFromTemplate('vite.config.js', path.join(this.projectDir, `vite.config.${this.jsExt}`));
this.createFileFromTemplate('Dockerfile', path.join(this.projectDir, 'Dockerfile'));
this.createFileFromTemplate('docker-compose.yml', path.join(this.projectDir, 'docker-compose.yml'));
this.createFileFromTemplate('app/index.html', path.join(this.projectDir, `app/index.html`));
this.createFileFromTemplate('app/main.jsx', path.join(this.projectDir, `app/main.${this.jsxExt}`));
this.createFileFromTemplate('app/_server/app.js', path.join(this.projectDir, `app/_server/app.${this.jsExt}`));

View File

@ -3,6 +3,8 @@ import { stripIndent } from "@kottster/common";
type TemplateVars = {
'vite.config.js': undefined;
'tsconfig.json': undefined;
'Dockerfile': undefined;
'docker-compose.yml': undefined;
'app/_server/app.js': undefined;
'app/_server/server.js': undefined;
'app/_server/data-sources/postgres/index.js': {
@ -127,6 +129,48 @@ export class FileTemplateManager {
}
`),
'Dockerfile': stripIndent(`
# For production deployment
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
ENV PORT=3000
EXPOSE $PORT
CMD ["node", "dist/server/server.cjs"]
`),
'docker-compose.yml': stripIndent(`
# For production deployment
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- PORT=3000
`),
'app/_server/app.js': stripIndent(`
import { createApp } from '@kottster/server';
import schema from '../../kottster-app.json';

View File

@ -1,6 +1,6 @@
{
"name": "@kottster/cli",
"version": "3.0.2",
"version": "3.1.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1110,9 +1110,9 @@
"dev": true
},
"@kottster/common": {
"version": "3.0.1",
"resolved": "https://registry.yarnpkg.com/@kottster/common/-/common-3.0.1.tgz",
"integrity": "sha1-NcFT/q4T03aRu5st/jpk5L35rFA= sha512-wOUDHFfwh/gMtOGHiZeFdR4nG3NawzFbLCIdX1UuYOxgYstr1E4kUNFJL6yXKGpLcx06lxwv4o4x0DZdh3Ldmg==",
"version": "3.0.6",
"resolved": "https://registry.yarnpkg.com/@kottster/common/-/common-3.0.6.tgz",
"integrity": "sha1-Y1cetuDb3maVnD0D7ARxe1AS0co= sha512-38ubX4CmJXTRDFXAodSyEoJgIzJk2PE2Y1nADldxk/tgglzYn7BD0xE6Xj/7Ly4X38glUQNObb59KhDYdfvVDQ==",
"dev": true
},
"@nodelib/fs.scandir": {

View File

@ -1,6 +1,6 @@
{
"name": "@kottster/cli",
"version": "3.0.2",
"version": "3.1.1",
"description": "CLI for Kottster",
"main": "dist/index.js",
"license": "Apache-2.0",
@ -32,7 +32,7 @@
"@babel/traverse": "^7.24.1",
"@babel/types": "^7.24.0",
"@eslint/js": "^9.2.0",
"@kottster/common": "^3.0.1",
"@kottster/common": "^3.0.6",
"@types/babel__traverse": "^7.20.5",
"@types/cross-spawn": "^6.0.6",
"@types/dotenv": "^8.2.0",

View File

@ -899,10 +899,10 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@kottster/common@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@kottster/common/-/common-3.0.1.tgz#35c153feae13d37691bb9b2dfe3a64e4bdf9ac50"
integrity sha512-wOUDHFfwh/gMtOGHiZeFdR4nG3NawzFbLCIdX1UuYOxgYstr1E4kUNFJL6yXKGpLcx06lxwv4o4x0DZdh3Ldmg==
"@kottster/common@^3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@kottster/common/-/common-3.0.6.tgz#63571eb6e0dbde66959c3d03ec04717b5012d1ca"
integrity sha512-38ubX4CmJXTRDFXAodSyEoJgIzJk2PE2Y1nADldxk/tgglzYn7BD0xE6Xj/7Ly4X38glUQNObb59KhDYdfvVDQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"

View File

@ -1,4 +1,4 @@
import { PageFileStructure } from "@kottster/common";
import { PageFileStructure, Stage } from "@kottster/common";
import { DevAction } from "../models/action.model";
import { FileReader } from "../services/fileReader.service";
import { FileWriter } from "../services/fileWriter.service";
@ -14,7 +14,7 @@ interface Data {
export class CreatePage extends DevAction {
public async executeDevAction(data: Data) {
const fileWriter = new FileWriter({ usingTsc: this.app.usingTsc });
const fileReader = new FileReader();
const fileReader = new FileReader(this.app.stage === Stage.development);
const appSchema = fileReader.readSchemaJsonFile();
// Add page file

View File

@ -1,3 +1,4 @@
import { Stage } from "@kottster/common";
import { DevAction } from "../models/action.model";
import { FileReader } from "../services/fileReader.service";
import { FileWriter } from "../services/fileWriter.service";
@ -12,7 +13,7 @@ interface Data {
export class DeletePage extends DevAction {
public async executeDevAction(data: Data) {
const fileWriter = new FileWriter({ usingTsc: this.app.usingTsc });
const fileReader = new FileReader();
const fileReader = new FileReader(this.app.stage === Stage.development);
const { key } = data;
const appSchema = fileReader.readSchemaJsonFile();

View File

@ -1,4 +1,4 @@
import { ClientAppSchema } from "@kottster/common";
import { ClientAppSchema, Page, Stage } from "@kottster/common";
import { Action } from "../models/action.model";
import { FileReader } from "../services/fileReader.service";
@ -6,10 +6,22 @@ import { FileReader } from "../services/fileReader.service";
* Get the app schema
*/
export class GetAppSchema extends Action {
private cachedPages: Page[] | null = null;
public async execute(): Promise<ClientAppSchema> {
const fileReader = new FileReader();
const appSchema = fileReader.readSchemaJsonFile();
const pages = fileReader.getPageConfigs();
const fileReader = new FileReader(this.app.stage === Stage.development);
// Cache pages in production to avoid reading files every time
const pages = this.app.stage === Stage.production && this.cachedPages
? this.cachedPages
: fileReader.getPageConfigs();
if (this.app.stage === Stage.production && !this.cachedPages) {
this.cachedPages = pages;
}
// In production, use the in-memory schema; in development, read from file
const appSchema = this.app.stage === Stage.production ? this.app.schema : fileReader.readSchemaJsonFile();
return {
...appSchema,

View File

@ -1,4 +1,4 @@
import { AppSchema } from "@kottster/common";
import { AppSchema, Stage } from "@kottster/common";
import { DevAction } from "../models/action.model";
import { FileReader } from "../services/fileReader.service";
import { FileWriter } from "../services/fileWriter.service";
@ -13,7 +13,7 @@ interface Data {
export class UpdateAppSchema extends DevAction {
public async executeDevAction(data: Data) {
const fileWriter = new FileWriter({ usingTsc: this.app.usingTsc });
const fileReader = new FileReader();
const fileReader = new FileReader(this.app.stage === Stage.development);
const { menuPageOrder } = data;
const appSchema = fileReader.readSchemaJsonFile();

View File

@ -1,4 +1,4 @@
import { Page } from "@kottster/common";
import { Page, Stage } from "@kottster/common";
import { DevAction } from "../models/action.model";
import { FileReader } from "../services/fileReader.service";
import { FileWriter } from "../services/fileWriter.service";
@ -14,7 +14,7 @@ interface Data {
export class UpdatePage extends DevAction {
public async executeDevAction(data: Data) {
const fileWriter = new FileWriter({ usingTsc: this.app.usingTsc });
const fileReader = new FileReader();
const fileReader = new FileReader(this.app.stage === Stage.development);
const { key, page } = data;
const appSchema = fileReader.readSchemaJsonFile();

View File

@ -45,7 +45,7 @@ export class KottsterApp {
private readonly secretKey: string;
public readonly usingTsc: boolean;
public readonly readOnlyMode: boolean = false;
public readonly stage: Stage = process.env.NODE_ENV === Stage.development ? Stage.development : Stage.production;
public readonly stage: Stage = process.env.KOTTSTER_APP_STAGE === Stage.development ? Stage.development : Stage.production;
public dataSources: DataSource[] = [];
public schema: AppSchema;
private customEnsureValidToken?: (request: Request) => Promise<EnsureValidTokenResponse>;
@ -123,7 +123,7 @@ export class KottsterApp {
return;
}
} catch (error) {
console.error('Error handling internal API request:', error);
console.error('Internal API error:', error);
res.status(500).json({ error: 'Internal Server Error' });
return;
}
@ -153,7 +153,7 @@ export class KottsterApp {
result: await this.executeAction(action, actionData),
};
} catch (error) {
console.error('Error handling Kottster API request:', error);
console.error('Kottster API error:', error);
result = {
status: 'error',
@ -472,7 +472,7 @@ export class KottsterApp {
let user: User;
if (this.schema.enterpriseHub) {
const response = await fetch(`${this.schema.enterpriseHub.url}/apps/${this.appId}/users/current`, {
const response = await fetch(`${this.schema.enterpriseHub.url}/v1/apps/${this.appId}/users/current`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,

View File

@ -57,8 +57,7 @@ export class KottsterServer {
}
private setupServiceRoutes() {
this.expressApp.use('/internal-api/', this.app.getInternalApiRoute());
// this.expressApp.use('/devsync-api/', this.app.getDevSyncApiRoute());
this.expressApp.use('/internal-api', this.app.getInternalApiRoute());
}
private setupWebSocketHealthCheck() {
@ -87,7 +86,7 @@ export class KottsterServer {
private async setupDynamicDataSources() {
const isDevelopment = this.app.stage === Stage.development;
const fileReader = new FileReader();
const fileReader = new FileReader(isDevelopment);
// Dynamically load data sources from the data-sources directory
const dataSourcesDir = isDevelopment ? `${PROJECT_DIR}/app/_server/data-sources` : `${PROJECT_DIR}/dist/server/data-sources`;
@ -96,7 +95,6 @@ export class KottsterServer {
if (!fs.existsSync(dataSourcesDir)) {
return;
}
const dataSources: DataSource[] = [];
const dataSourceConfigs = fileReader.getDataSourceConfigs();
@ -138,40 +136,43 @@ export class KottsterServer {
private async setupDynamicRoutes() {
const isDevelopment = this.app.stage === Stage.development;
const fileReader = new FileReader();
const fileReader = new FileReader(isDevelopment);
const pageConfigs = fileReader.getPageConfigs();
// Set routes for pages specified in the schema
if (pageConfigs) {
for (const pageConfig of pageConfigs) {
const pagesDir = isDevelopment ? `${PROJECT_DIR}/app/pages` : `${PROJECT_DIR}/dist/server/pages`;
const usingTsc = this.app.usingTsc;
const apiPath = path.join(pagesDir, pageConfig.key, isDevelopment ? `api.server.${usingTsc ? 'ts' : 'js'}` : 'api.cjs');
// If the page is custom or has a defined api.server.js file, load it
if (pageConfig.type === 'custom' || fs.existsSync(apiPath)) {
try {
const routeModule = await import(apiPath);
if (routeModule.default && typeof routeModule.default === 'function') {
const routePath = `/api/${pageConfig.key}`;
this.expressApp.post(routePath, this.app.createRequestWithPageDataMiddleware(pageConfig), routeModule.default);
try {
const pagesDir = isDevelopment ? `${PROJECT_DIR}/app/pages` : `${PROJECT_DIR}/dist/server/pages`;
const usingTsc = this.app.usingTsc;
const apiPath = path.join(pagesDir, pageConfig.key, isDevelopment ? `api.server.${usingTsc ? 'ts' : 'js'}` : 'api.cjs');
// If the page is custom or has a defined api.server.js file, load it
if (pageConfig.type === 'custom' || fs.existsSync(apiPath)) {
try {
const routeModule = await import(apiPath);
if (routeModule.default && typeof routeModule.default === 'function') {
const routePath = `/api/${pageConfig.key}`;
this.expressApp.post(routePath, this.app.createRequestWithPageDataMiddleware(pageConfig), routeModule.default);
}
} catch (error) {
console.error(`Failed to load route "${pageConfig.key}":`, error);
}
} catch (error) {
console.error(`Failed to load route "${pageConfig.key}":`, error);
}
} else {
if (pageConfig.type === 'table') {
if (!pageConfig.config.dataSource) {
console.warn(`Page "${pageConfig.key}" does not have a data source specified. Skipping route setup.`);
continue;
} else {
if (pageConfig.type === 'table') {
if (!pageConfig.config.dataSource) {
console.warn(`Page "${pageConfig.key}" does not have a data source specified. Skipping route setup.`);
continue;
}
this.expressApp.post(`/api/${pageConfig.key}`, this.app.createRequestWithPageDataMiddleware(pageConfig), this.app.defineTableController(pageConfig.config));
}
if (pageConfig.type === 'dashboard') {
this.expressApp.post(`/api/${pageConfig.key}`, this.app.createRequestWithPageDataMiddleware(pageConfig), this.app.defineDashboardController(pageConfig.config));
}
this.expressApp.post(`/api/${pageConfig.key}`, this.app.createRequestWithPageDataMiddleware(pageConfig), this.app.defineTableController(pageConfig.config));
}
if (pageConfig.type === 'dashboard') {
this.expressApp.post(`/api/${pageConfig.key}`, this.app.createRequestWithPageDataMiddleware(pageConfig), this.app.defineDashboardController(pageConfig.config));
}
} catch (error) {
console.error(`Error setting up route for page "${pageConfig.key}":`, error);
}
};
}
@ -196,7 +197,7 @@ export class KottsterServer {
}
});
} else {
throw new Error(`Client directory not found: ${clientDir}`);
console.warn(`Client directory not found: ${clientDir}`);
}
}

View File

@ -7,6 +7,7 @@ import { PageFileStructure, File, AppSchema, Page, DataSource } from "@kottster/
* Service for reading files
*/
export class FileReader {
constructor(private readonly isDevelopment?: boolean) {}
/**
* Read the schema from the kottster-app.json file
@ -40,7 +41,7 @@ export class FileReader {
* @returns The page directories
*/
public getPagesDirectories(): string[] {
const dir = `${PROJECT_DIR}/app/pages`;
const dir = this.isDevelopment ? `${PROJECT_DIR}/app/pages` : `${PROJECT_DIR}/dist/server/pages`;
if (!fs.existsSync(dir)) {
return [];
}
@ -53,7 +54,7 @@ export class FileReader {
* @returns The data source directories
*/
public getDataSourceDirectories(): string[] {
const dir = `${PROJECT_DIR}/app/_server/data-sources`;
const dir = this.isDevelopment ? `${PROJECT_DIR}/app/_server/data-sources` : `${PROJECT_DIR}/dist/server/data-sources`;
if (!fs.existsSync(dir)) {
return [];
}
@ -70,7 +71,7 @@ export class FileReader {
const result: Omit<DataSource, 'status' | 'adapter'>[] = [];
for (const dir of dataSourceDirectories) {
const dataSourceJsonPath = path.join(PROJECT_DIR, `app/_server/data-sources/${dir}/dataSource.json`);
const dataSourceJsonPath = path.join(PROJECT_DIR, this.isDevelopment ? `app/_server/data-sources/${dir}/dataSource.json` : `dist/server/data-sources/${dir}/dataSource.json`);
if (!fs.existsSync(dataSourceJsonPath)) {
console.warn(`Data source config not found for directory: ${dir}`);
continue;
@ -136,7 +137,7 @@ export class FileReader {
* @returns The page structure or null if the page does not exist
*/
public getPageFileStructure(pageKey: string): PageFileStructure | null {
const dirPath = `app/pages/${pageKey}`;
const dirPath = this.isDevelopment ? `app/pages/${pageKey}` : `dist/server/pages/${pageKey}`;
const absoluteDirPath = `${PROJECT_DIR}/${dirPath}`;
const filePaths = this.getAllFilePathsInDirectory(absoluteDirPath);

View File

@ -1,6 +1,6 @@
{
"name": "@kottster/server",
"version": "3.0.7",
"version": "3.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@kottster/server",
"version": "3.0.7",
"version": "3.1.0",
"description": "Instant admin panel for your project",
"keywords": [
"admin",