Last summer, in the process of preparing an article for Habr, I was not too lazy to pack my template for backend applications on Node.js in an npm package, making it a cli-utility for quick start.
There was no hope that anyone other than me would use this package initially. However, when I decided to update the template by introducing the features I needed, I noticed that the npm package has several dozens of downloads per week, and the project on the github has 12 stars. Delivered in kindness by good people, surely to support me, not the project. Only 12 stars, but it was enough for me to decide that I will develop karcass as if it were necessary not only to me.
Despite the fact that initially I decided to create a lightweight framework for backend applications, during the development process I managed to convince myself that this bike was not needed. And that karcass should not be a framework, but a universal tool for creating applications from templates.

In the first version, the logic of the cli-script was primitive.
- .
- karcass template .
- , ( - , ) ( ).
- ,
npm install
.
:

. , .
, : Application , , . , :
Application.tsimport Express from 'express'
import { AbstractConsoleCommand } from './Base/Console/AbstractConsoleCommand'
import { DbService } from './Database/Service/DbService'
import { HelpCommand } from './Base/Console/HelpCommand'
import { LoggerService } from './Logger/Service/LoggerService'
import { IssueService } from './Project/Service/IssueService'
import { GitlabService } from './Gitlab/Service/GitlabService'
import { LocalCacheService } from './Base/Service/LocalCacheService'
import { ProjectService } from './Project/Service/ProjectService'
import { GroupService } from './Project/Service/GroupService'
import { UserService } from './User/Service/UserService'
import { UpdateProjectsCommand } from './Gitlab/Console/UpdateProjectsCommand'
import { CreateMigrationCommand } from './Database/Console/CreateMigrationCommand'
import { MigrateCommand } from './Database/Console/MigrateCommand'
import { MigrateUndoCommand } from './Database/Console/MigrateUndoCommand'
import IssueController from './Project/Controller/IssueController'
import fs from 'fs'
export class Application {
public http!: Express.Express
public localCacheService!: LocalCacheService
public loggerService!: LoggerService
public dbService!: DbService
public gitlabService!: GitlabService
public issueService!: IssueService
public projectService!: ProjectService
public groupService!: GroupService
public userService!: UserService
public helpCommand!: HelpCommand
public createMigrationCommand!: CreateMigrationCommand
public migrateCommand!: MigrateCommand
public migrateUndoCommand!: MigrateUndoCommand
public updateProjectsCommand!: UpdateProjectsCommand
public issueController!: IssueController
public constructor(public readonly config: IConfig) {
if (config.columns.length < 2) {
throw new Error('There are too few columns :-(')
}
}
public async run() {
this.initializeServices()
if (process.argv[2]) {
this.initializeCommands()
for (const command of Object.values(this)
.filter((c: any) => c instanceof AbstractConsoleCommand) as AbstractConsoleCommand[]
) {
if (command.name === process.argv[2]) {
await command.execute()
process.exit()
}
}
await this.helpCommand.execute()
process.exit()
} else {
this.runWebServer()
}
}
protected runWebServer() {
this.initCron()
this.http = Express()
this.http.use('/', Express.static('vue/dist'))
this.http.use((req, res, next) => {
if (req.url.indexOf('/api') === -1) {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate')
res.header('Expires', '-1')
res.header('Pragma', 'no-cache')
return res.send(fs.readFileSync('vue/dist/index.html').toString())
}
next()
})
this.http.use(Express.urlencoded())
this.http.use(Express.json())
this.http.listen(this.config.listen, () => console.log(`Listening on port ${this.config.listen}`))
this.initializeControllers()
}
protected initCron() {
if (this.config.gitlab.updateInterval) {
setInterval(async () => {
if (!this.updateProjectsCommand) {
this.updateProjectsCommand = new UpdateProjectsCommand(this)
}
await this.updateProjectsCommand.execute()
}, this.config.gitlab.updateInterval * 1000)
}
}
protected initializeServices() {
this.localCacheService = new LocalCacheService(this)
this.gitlabService = new GitlabService(this)
this.loggerService = new LoggerService(this)
this.dbService = new DbService(this)
this.issueService = new IssueService(this)
this.projectService = new ProjectService(this)
this.groupService = new GroupService(this)
this.userService = new UserService(this)
}
protected initializeCommands() {
this.helpCommand = new HelpCommand(this)
this.createMigrationCommand = new CreateMigrationCommand(this)
this.migrateCommand = new MigrateCommand(this)
this.migrateUndoCommand = new MigrateUndoCommand(this)
this.updateProjectsCommand = new UpdateProjectsCommand(this)
}
protected initializeControllers() {
this.issueController = new IssueController(this)
}
}
ProjectService.tsimport { AbstractService } from '../../Base/Service/AbstractService'
import { Project } from '../Entity/Project'
export class ProjectService extends AbstractService {
public get projectRepository() {
return this.app.dbService.connection.getRepository(Project)
}
public async updateProjects(allTime = false) {
await this.app.groupService.updateGroups()
for (const data of await this.app.gitlabService.getProjects()) {
let project = await this.getProject(data.id)
if (!project) {
project = this.projectRepository.create({ id: data.id })
}
project.name = data.name
project.url = data.web_url
project.updatedTimestamp = Math.round(new Date(data.last_activity_at).getTime() / 1000)
project.groupId = data.namespace && data.namespace.kind === 'group' ? data.namespace.id : null
await this.projectRepository.save(project)
await this.app.issueService.updateProjectIssues(project, allTime)
}
}
public async getProject(id: number): Promise<Project|undefined> {
return id ? this.app.localCacheService.get(`project.${id}`, () => this.projectRepository.findOne(id)) : undefined
}
}
, , , . , DI-, cli.
Application.ts
, ยซ ยป. .
Application.tsimport CreateExpress, { Express } from 'express';
import { TwingEnvironment, TwingLoaderFilesystem } from 'twing';
import { Container } from '@karcass/container';
import { Cli } from '@karcass/cli';
import { Connection, createConnection } from 'typeorm';
import { CreateMigrationCommand, MigrateCommand, MigrateUndoCommand } from '@karcass/migration-commands';
import { createLogger } from './routines/createLogger';
import { Logger } from 'winston';
import { FrontPageController } from './SampleBundle/Controller/FrontPageController';
import { Message } from './SampleBundle/Entity/Message';
import { MessagesService } from './SampleBundle/Service/MessagesService';
export class Application {
private container = new Container();
private console = new Cli();
private controllers: object[] = [];
private http!: Express;
public constructor(public readonly config: IConfig) { }
public async run() {
await this.initializeServices();
if (process.argv[2]) {
this.initializeCommands();
await this.console.run();
} else {
this.runWebServer();
}
}
protected runWebServer() {
this.http = CreateExpress();
this.http.use('/public', CreateExpress.static('public'));
this.http.use(CreateExpress.urlencoded());
this.http.listen(this.config.listen, () => console.log(`Listening on port ${this.config.listen}`));
this.container.add<Express>('express', () => this.http);
this.container.add(TwingEnvironment, () => new TwingEnvironment(new TwingLoaderFilesystem('src')));
this.initializeControllers();
}
protected async initializeServices() {
await this.container.addInplace<Logger>('logger', () => createLogger(this.config.logdir));
const typeorm = await this.container.addInplace(Connection, () => createConnection({
type: 'sqlite',
database: 'db/sample.sqlite',
entities: ['build/**/Entity/*.js'],
migrations: ['build/**/Migrations/*.js'],
logging: ['error', 'warn', 'migration'],
}));
this.container.add('Repository<Message>', () => typeorm.getRepository(Message));
this.container.add(MessagesService);
}
protected initializeCommands() {
this.console.add(CreateMigrationCommand, () => new CreateMigrationCommand());
this.console.add(MigrateCommand, async () => new MigrateCommand(await this.container.get(Connection)));
this.console.add(MigrateUndoCommand, async () => new MigrateUndoCommand(await this.container.get(Connection)));
}
protected async initializeControllers() {
this.controllers.push(
await this.container.inject(FrontPageController),
);
}
}
TypeScript :
FrontPageController.tsimport { Express } from 'express';
import { Dependency } from '@karcass/container';
import { TwingEnvironment } from 'twing';
import { AbstractController, QueryData } from './AbstractController';
import { MessagesService } from '../Service/MessagesService';
export class FrontPageController extends AbstractController {
public constructor(
@Dependency('express') protected express: Express,
@Dependency(TwingEnvironment) protected twing: TwingEnvironment,
@Dependency(MessagesService) protected messagesService: MessagesService,
) {
super(express);
this.onQuery('/', 'get', this.frontPageAction);
this.onQuery('/', 'post', this.sendMessageAction);
}
public async sendMessageAction(data: QueryData) {
await this.messagesService.addMessage(data.params.text);
data.res.redirect('/');
}
public async frontPageAction() {
if (await this.messagesService.isEmpty()) {
await this.messagesService.createSampleMessages();
}
return this.twing.render('SampleBundle/Views/front.twig', {
messages: await this.messagesService.getMessages(),
});
}
}
JavaScript, ยซยป:
protected async initializeControllers() {
this.controllers.push(
new FrontPageController(
await this.container.get('express'),
await this.container.get(TwingEnvironment),
await this.container.get(MessagesService),
),
);
}
template
karcass, : . , .
: TemplateReducer.ts
TemplateReducer.js
, TemplateReducer
, :
interface TemplateReducerInterface {
getConfigParameters(): Promise<ConfigParametersResult>
getConfig(): Record<string, any>
setConfig(config: Record<string, any>): void
getDirectoriesForRemove(): Promise<string[]>
getFilesForRemove(): Promise<string[]>
getDependenciesForRemove(): Promise<string[]>
getFilesContentReplacers(): Promise<ReplaceFileContentItem[]>
finish(): Promise<void>
getTestConfigSet(): Promise<Record<string, any>[]>
}
, , , - , karcass , JavaScript/TypeScript. -. - webpack. , - create-react-app
... , vue create
.
TemplateReducerInterface
, , . karcass:
hello
index.js
:
console.log('Hello, [replacethisname]!')
TemplateReducer.js
, karcass' :
const reducer = require('@karcass/template-reducer')
const Type = reducer.ConfigParameterType
class TemplateReducer extends reducer.AbstractTemplateReducer {
getConfigParameters() {
return [
{ name: 'name', description: 'Please enter your name', type: Type.string },
]
}
async getFilesContentReplacers() {
return [
{ filename: 'index.js', replacer: (content) => {
return content.replace('[replacethisname]', this.config.name)
} },
]
}
async finish() {
console.log(`Application installed, to launch it execute\n cd ${this.directoryName} && node index.js`)it.`)
}
}
module.exports = { TemplateReducer }
, , , โ @karcass/template-reducer
, , package.json
:
npm init && npm install @karcass/template-reducer
, getDependenciesForRemove
, karcass .
karcass . , .

, karcass . github.com, :
npx karcass create helloworld https://github.com/karcass-ts/hello-world
, -, :
npx karcass create ooohhhh-ok-show-it
? , TemplateReducer
helloworld- :
getTestConfigSet() {
return [
{ name: 'testname1' },
{ name: 'testname2' },
]
}
:
npx karcass test www/karcass/hello
โ . , , , :

I decided not to do a longride with a description of all the features of karcass, but to make a small fact-finding note for the purpose of presentation and collecting feedback: any feedback will be useful to me to understand where to go next and whether it is worth it. In the meantime, writing documentation is a priority.
Karcass repository on github ;