flint_dart 1.2.2
flint_dart: ^1.2.2 copied to clipboard
A modern, expressive, and extensible server-side framework by Eulogia Technologies.
Flint Dart #
A modern, production‑ready Full-Stack Framework for Dart. Flint Dart empowers you to build complete, end-to-end applications (declarative browser frontend, real-time WebSocket communication, database ORM, mail/SMTP engine, authentication, and background task AI runtime) entirely in Dart.
- Website: flintdart.dev
- Status: Stable Release (v1.1.0)
- Maintainers: Eulogia Technologies
The Full-Stack Dart Advantage #
Flint Dart unifies client-side declarative UI rendering and server-side systems under one language:
- Frontend & UI (Flint UI): Flutter-like declarative UI widget engine for HTML, email templates, and high-performance server-side rendering (SSR) with progressive loading in the browser.
- Fast, Clean Routing: REST APIs and route groups with middleware-first security.
- Real-Time WebSockets: High-performance event-driven sockets with Socket.IO-like rooms and automated model payload serialization.
- Active Record ORM: Built‑in database mapping for MySQL and PostgreSQL with safe migrations.
- Request Validation: Auto-normalizes and validates incoming payloads (JSON, URL-encoded, or Multipart/Forms).
- Authentication: JWT + OAuth + refresh tokens and rate-limiting lockout.
- Mail Engine: SMTP client with
.flint.htmltemplate rendering support. - Swagger UI: Auto‑generated Swagger documentation from code annotations.
- Developer CLI: Full-featured toolchain to scaffold files, migrate databases, and run hot-reloads.
Installation #
One-command setup #
Install Dart, FlintDart, Flint UI, and PATH entries in one pass:
# macOS/Linux
curl -fsSL https://raw.githubusercontent.com/flint-dart/flint_dart/main/install.sh | sh
# Windows PowerShell
iwr https://raw.githubusercontent.com/flint-dart/flint_dart/main/install.ps1 -UseB | iex
See INSTALL.md for options and OS-specific details.
For packaged releases, Flint also has a compiled Rust installer:
cd flint/installer-app
cargo run --bin flint-install
1) Install CLI globally #
# Activate CLI
dart pub global activate flint_dart
# Create a new project
flint create my_app
cd my_app
# Run the app
flint run
2) Add to an existing project #
dart pub add flint_dart
Hello World #
import 'package:flint_dart/flint_dart.dart';
void main() {
final app = Flint();
app.get('/', (Context ctx) async {
return ctx.res?.send('Welcome to Flint Dart!');
});
app.listen(port: 3000);
}
Deployment Migrations #
Flint can run the normal table registry migration path once during server startup, before the HTTP port is bound:
final app = Flint(autoMigrate: true);
You can also enable it per environment without changing code:
FLINT_AUTO_MIGRATE=true
FLINT_AUTO_MIGRATE_CREATE_DB=false
FLINT_AUTO_MIGRATE_VERBOSE=false
Auto migration is off by default. When enabled, it runs once per server process
and uses the same migration engine as flint migrate.
Routing #
app.get('/users/:id', (Context ctx) async {
final id = ctx.req.param('id');
return ctx.res?.json({'id': id});
});
app.post('/users', (Context ctx) async {
final body = await ctx.req.json();
return ctx.res?.json({'created': true, 'data': body});
});
HTTP QUERY routes #
Flint supports the HTTP QUERY method from RFC 10008. QUERY is safe and
idempotent like GET, but it can carry request content like POST. Use it
for complex search or filter operations where the query expression is too large
or too structured for a URL query string, but the operation must not mutate
server state.
app.query('/products/search', (Context ctx) async {
final filters = await ctx.req.json();
final page = ctx.req.queryParam('page') ?? '1';
return ctx.res?.json({
'filters': filters,
'page': page,
});
});
ctx.req.query and ctx.req.queryParam() still read URI query string values
such as ?page=2. The QUERY request body is separate query content and can use
the same JSON, text, and form helpers as other body-carrying routes.
Compatibility note: some proxies, API gateways, browsers, servers, and API
tools may not support QUERY yet. See RFC 10008:
https://www.rfc-editor.org/rfc/rfc10008.html.
Controller-based routes (v1.0.2) #
Flint also supports request-scoped controllers for both HTTP and WebSocket routes.
Controller methods can use req, res, and socket directly after binding.
import 'package:flint_dart/flint_dart.dart';
class UserController extends Controller {
Future<Response> create() async {
final body = await req.json();
return res.json({'created': true, 'data': body});
}
}
class ChatController extends Controller {
void connect() {
socket.emit('connected', {'id': socket.id});
}
}
Route registration:
app.post(
'/users',
controllerAction(UserController.new, (c) => c.create()),
);
app.websocket(
'/chat',
controllerAction(ChatController.new, (c) {
c.connect();
return null;
}),
);
RouteGroup shorthand (no mixin required):
class UserRoutes extends RouteGroup {
@override
String get prefix => '/users';
@override
void register(Flint app) {
app.post('/', useController(UserController.new, (c) => c.create()));
}
}
Unified Context handlers #
Flint uses a unified Context object for route handlers.
ctx.reqis always available.ctx.resis available for HTTP routes.ctx.socketis available for WebSocket routes.ctx.extrasandctx.read<T>()/ctx.write<T>()support future request-scoped injection (e.g. session/user).
app.get('/health', (Context ctx) {
return ctx.res?.json({'ok': true});
});
app.websocket('/chat', (Context ctx) {
ctx.socket?.on('ping', (_) {
ctx.socket?.emit('pong', {'ok': true});
});
});
Request helpers #
ctx.req.param('id')— route parameterctx.req.queryParam('page')— query parameterctx.req.rawBody()— raw body bytes for custom decoding/signature checksctx.req.body()— raw body stringctx.req.input('key')— normalized input from query, JSON, form, multipart, files, and paramsctx.req.allInput()— all normalized inputctx.req.validate({...})— auto-detects request input and validates itctx.req.json()— JSON body (Map)ctx.req.form()— text form fields only (urlencoded or multipart)ctx.req.file('avatar')/ctx.req.files('gallery')— uploaded files
Response helpers #
ctx.res?.send('text')ctx.res?.json({...})ctx.res?.view('home', data: {...})ctx.res?.respond(data)— auto‑detects typectx.res?.back(fallback: '/settings')— redirect to previous URLctx.res?.withSuccess('Saved')/ctx.res?.withError('Failed')— flash messages for next template render
You can also return data directly from a handler. Flint will serialize:
Map/Listas JSON- primitives as text/auto response
- custom objects that implement
toMap()ortoJson()
app.post('/settings', (Context ctx) async {
final data = await ctx.req.validate({'name': 'required|string|min:2'});
// ... persist data
return ctx.res
?.withSuccess('Settings updated successfully.')
.back(fallback: '/settings');
});
class UserDto {
final int id;
final String email;
UserDto(this.id, this.email);
Map<String, dynamic> toMap() => {'id': id, 'email': email};
}
app.get('/me', (Context ctx) async {
return UserDto(1, 'ada@example.com'); // auto JSON via toMap()
});
Middleware #
Global middleware #
app.use(AuthMiddleware());
Route middleware #
app.get('/admin', handler).use(AuthMiddleware());
Route group middleware #
class AdminRoutes extends RouteGroup {
@override
String get prefix => '/admin';
@override
List<Middleware> get middlewares => [AuthMiddleware()];
@override
void register(Flint app) {
app.get('/users', (Context ctx) async => ctx.res?.json([]));
}
}
Built‑in middleware:
ExceptionMiddleware— error handlingCookieSessionMiddleware— cookies/sessionsCorsMiddleware— CORS headersLoggerMiddleware— request loggingStaticFileMiddleware— static files
Validation #
app.post('/register', (Context ctx) async {
final data = await ctx.req.validate({
'name': 'required|string|min:3',
'email': 'required|email',
'password': 'required|string|min:8',
}, messages: {
'email.required': 'Email is required.',
'password.min': 'Password must be at least :min characters.',
});
return ctx.res?.json({'ok': true, 'data': data});
});
validate(), input(), and allInput() now normalize incoming data for you, so the same handler can accept:
- JSON requests
application/x-www-form-urlencodedmultipart/form-data
Example:
app.post('/profile', (Context ctx) async {
final data = await ctx.req.validate({
'name': 'required|string|min:2',
'email': 'required|email',
'avatar': 'required',
});
final name = await ctx.req.input('name');
final avatar = await ctx.req.input('avatar'); // UploadedFile for multipart
return ctx.res?.json({
'ok': true,
'name': name,
'hasAvatar': avatar != null,
'data': data,
});
});
Use the lower-level helpers only when you need a specific payload shape:
json()when you want a strict JSON objectform()when you only want text form fieldsfile()/files()when working directly with uploadsrawBody()when you need the exact undecoded payload
WebSockets #
WebSocket emits now normalize common Dart values before JSON encoding, so you can send normal app objects without manually converting everything first.
app.websocket('/chat', (Context ctx) {
ctx.socket?.emit('connected', {
'id': ctx.socket?.id,
'connectedAt': DateTime.now(),
'profile': UserDto(1, 'ada@example.com'),
});
});
That means values like DateTime, nested List/Map data, and custom objects with toMap() or toJson() are converted safely before they are sent over the socket.
Views & Templates #
Flint’s view engine uses .flint.html templates with a small set of processors. Views live in
lib/views and can extend layouts, include partials, loop, and render variables.
Render a view #
app.get('/', (Context ctx) async {
return ctx.res?.view('home', data: {'title': 'Flint Docs'});
});
Layouts, sections, and yield #
<!-- lib/views/layouts/app.flint.html -->
<!doctype html>
<html>
<head>
<title>{{ title ?? 'Flint' }}</title>
</head>
<body>
{{ yield('content') }}
</body>
</html>
<!-- lib/views/home.flint.html -->
{{ extends('layouts.app') }}
{{ section('content') }}
<h1>{{ title }}</h1>
{{ endsection }}
Includes (partials) #
{{ include('partials.nav') }}
Variables #
<h2>{{ user.name }}</h2>
Conditionals #
{{ if user }}
<p>Welcome back, {{ user.name }}</p>
{{ endif }}
Loops #
<ul>
{{ for item in items }}
<li>{{ item }}</li>
{{ endfor }}
</ul>
Switch / Case #
{{ switch status }}
{{ case 'paid' }}<span>Paid</span>{{ endcase }}
{{ case 'pending' }}<span>Pending</span>{{ endcase }}
{{ default }}<span>Unknown</span>{{ enddefault }}
{{ endswitch }}
Built‑in Processors #
extends— layout inheritancesection/yield— slot content into layoutsinclude— partials/partials with datavariables—{{ ... }}interpolationif_statement—if/endiffor_loop—for/endforswitch_cases—switch/case/defaultcomment— template commentsassets— asset helper tagssession— session/error helpers in templatesold_processor— legacy tags support
Mail (SMTP + Flint Templates) #
Flint supports sending email with SMTP and rendering email bodies from .flint.html templates.
Auto connect mail on app start #
Mail config is loaded automatically when the server starts:
final app = Flint(); // autoConnectMail is true by default
await app.listen(port: 3000);
If you want full manual control:
final app = Flint(autoConnectMail: false);
await MailConfig.load(); // manual setup from .env
await app.listen(port: 3000);
.env mail config #
MAIL_PROVIDER=custom # custom | gmail | outlook | yahoo
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=your_username
MAIL_PASSWORD=your_password
MAIL_ENCRYPTION=tls # tls | ssl
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME=My App
Send mail directly #
await Mail()
.to('user@example.com')
.subject('Welcome')
.html('<h1>Hello</h1>')
.text('Hello')
.sendMail();
Text-only mail:
await Mail()
.to('user@example.com')
.subject('Plain Text Message')
.text('Hello from Flint Mail')
.sendMail();
Use Flint view templates for mail (ViewMailable) #
Generate a mail class + template:
flint make:mail welcome
This creates:
lib/mail/welcome_mail.dartlib/mail/views/welcome.flint.html
Example:
import 'package:flint_dart/mail.dart';
class WelcomeMail extends ViewMailable {
final String recipientName;
final String recipientEmail;
WelcomeMail({
required this.recipientName,
required this.recipientEmail,
});
@override
String get subject => 'Welcome';
@override
String get view => 'mail/views/welcome.flint.html';
@override
Map<String, dynamic> get data => {
'recipientName': recipientName,
};
@override
List<String> get to => [recipientEmail];
}
await WelcomeMail(
recipientName: 'Ada',
recipientEmail: 'ada@example.com',
).send();
Template rendering notes #
- For web pages, use
ctx.res?.view('home', data: {...})with templates inlib/views. - For mail templates,
ViewMailablerenders your.flint.htmlfile viaTemplateEngine().render(...). - If you are looking for old names like
viewFlintTemplateorViewable, use the current APIs above (view,TemplateEngine,ViewMailable).
ORM (CRUD) #
// READ
final user = await User().find(1);
final users = await User()
.where('email', 'test@example.com')
.orderBy('created_at', desc: true)
.limit(10)
.get();
// CREATE
final created = await User().create({
'name': 'Ada',
'email': 'ada@example.com',
'password': 'secret',
});
// UPDATE
await User()
.where('id', 1)
.update(data: {'name': 'Ada Lovelace'});
// DELETE
await User().delete(1);
Extra ORM helpers #
save()— create/update based on PKfirstOrCreate(where, data)upsert(where, data)/upsertMany([...])countAll()/countWhere(field, value)
Seeders #
Flint seeders are meant to run through a registry file.
The main entry point is:
lib/config/seeder_registry.dart
That registry is what flint --db-seed runs, and it is the file the seeder generator updates when you create new seeders.
Example registry:
import 'package:flint_dart/flint_dart.dart';
import '../seeders/user_model_seeder.dart';
import '../seeders/post_model_seeder.dart';
Future<void> main() async {
await runSeeders([
UserModelSeeder(),
PostModelSeeder(),
]);
}
Create a seeder:
flint make:seeder UserModelSeeder
Run all registered seeders:
flint --db-seed
If you want people on your team to follow one pattern, this is the one to emphasize:
- create seeders in
lib/seeders - register them in
lib/config/seeder_registry.dart - run them through
flint --db-seed
Models & Tables #
class User extends Model<User> {
User() : super(() => User());
String get name => getAttribute('name');
String get email => getAttribute('email');
@override
Table get table => Table(
name: 'users',
columns: [
Column(name: 'name', type: ColumnType.string, length: 255),
Column(name: 'email', type: ColumnType.string, length: 255),
],
);
}
Models In Flint UI #
FlintDart models are server-side objects. Keep them in controllers, services, jobs, and seeders where database access is available.
When sending data to Flint UI, convert models to maps:
class UiController {
Future<Response> dashboard(Request req, Response res) async {
final users = await User().all();
return res.page(
'Dashboard',
title: 'Dashboard',
script: '/main.dart.js',
props: {
'users': users.map((user) => user.toMap()).toList(),
},
);
}
}
Then refresh the same resource in Flint UI with FlintModelApi:
final users = ResourceController<List<FlintModelRecord>>(
loader: () => FlintModelApi<FlintModelRecord>.records('/users').list(),
);
Think of the bridge as:
Model<T> on server -> JSON -> FlintModelRecord or typed DTO in browser
That keeps database code out of the browser while preserving a model-shaped developer experience.
Relations #
class User extends Model<User> {
@override
Map<String, RelationDefinition> get relations => {
'posts': Relations.hasMany('posts', () => Post()),
};
}
class Post extends Model<Post> {
@override
Map<String, RelationDefinition> get relations => {
'author': Relations.belongsTo('author', () => User()),
};
}
final posts = await Post().withRelation('author').get();
Auth (JWT + Providers) #
Register / Login #
final user = await Auth.register(
email: data['email'],
password: data['password'],
name: data['name'],
additionalData: {'role': 'admin'},
);
final result = await Auth.login(data['email'], data['password']);
Access + Refresh Tokens (Optional) #
Refresh token support is opt-in and disabled by default to keep framework behavior flexible.
final tokens = await Auth.loginWithTokens(
data['email'],
data['password'],
throttleKey: req.ipAddress, // optional
ipAddress: req.ipAddress, // optional metadata
userAgent: req.headers['user-agent'],
deviceName: 'web',
);
final rotated = await Auth.refreshAccessToken(
tokens['refreshToken'],
rotateRefreshToken: true,
);
Revoke helpers:
await Auth.revokeRefreshToken(refreshToken);
await Auth.revokeAllRefreshTokensForUser(userId);
Login Throttle (Optional) #
Login throttle/lockout is also opt-in and disabled by default.
final result = await Auth.login(
data['email'],
data['password'],
throttleKey: req.ipAddress, // optional (ip/device/etc)
);
OAuth Providers #
final url = Auth.providerRedirectUrl(
provider: 'google',
redirectPath: '/auth/google/callback',
);
Supported providers: Google, GitHub, Facebook, Apple.
Auth Environment Options #
# Existing
JWT_SECRET=change-me-to-a-strong-secret
JWT_EXPIRY_HOURS=24
PASSWORD_MIN_LENGTH=6
# Optional refresh-token flow
AUTH_ENABLE_REFRESH_TOKENS=false
AUTH_ACCESS_TOKEN_MINUTES=60
AUTH_REFRESH_TOKEN_DAYS=30
# Optional login throttle
AUTH_ENABLE_LOGIN_THROTTLE=false
AUTH_LOGIN_MAX_ATTEMPTS=5
AUTH_LOGIN_LOCK_MINUTES=15
File Uploads & Storage #
final upload = await ctx.req.file('avatar');
if (upload != null) {
final path = await ctx.req.storeFile('avatar', directory: 'public/uploads');
}
AI Runtime #
Flint now exposes one AI system only: ai.
The AI layer is organized around:
- providers
- tools
- workflows
- memory
- runtime
Framework entry points #
final app = Flint();
app.ai.useOpenAiFromEnv();
app.get('/ai/chat', (Context ctx) async {
final result = await ctx.ai.chat(
providerId: 'openai',
request: const ChatRequest(
model: 'gpt-4o-mini',
messages: [
ChatMessage(role: 'user', content: 'Say hello'),
],
),
);
return ctx.res?.json(result.toMap());
});
Provider credentials can come from .env:
app.ai.useOpenAiFromEnv();
app.ai.useGeminiFromEnv();
app.ai.useAnthropicFromEnv();
// Or register every provider that has credentials configured.
final providers = app.ai.useChatProvidersFromEnv();
Supported keys are OPENAI_API_KEY, OPENAI_BEARER_TOKEN,
GEMINI_API_KEY, GEMINI_BEARER_TOKEN, ANTHROPIC_API_KEY, plus optional
*_CHAT_ENDPOINT overrides.
You can also opt into stricter production defaults:
final ai = FlintAi.production(
memoryStore: FlintAutoAiMemoryStore(),
repository: FlintAutoAiRepository(),
);
Register providers, tools, workflows, memory, and persistence #
final app = Flint();
app.ai.useChatProvidersFromEnv();
app.ai.registerTool(SummarizeTicketTool());
app.ai.registerWorkflow(SupportEscalationWorkflow());
// Optional explicit production wiring
final hardenedAi = FlintAi.production(
memoryStore: FlintAutoAiMemoryStore(),
repository: FlintAutoAiRepository(),
);
Tool policy can also be hardened from .env:
app.ai.useProductionToolPolicyFromEnv();
await ctx.ai.run(
agent: SupportAgent(),
goal: const AiGoal(task: 'Summarize ticket'),
userId: staff.id.toString(),
metadata: {
'role': staff.role,
'capabilities': ['support:write'],
},
);
Use AI_ALLOWED_TOOLS, AI_ALLOWED_CAPABILITIES, and AI_ALLOWED_ROLES for
explicit allow-lists.
Runtime model #
AiAgentdefines planning and synthesisAiToolperforms controlled side effectsAiWorkflowhandles higher-level orchestrationAiMemoryStoretracks run memoryAiRepositorypersists runs, traces, threads, and artifacts
Memory and persistence APIs #
await ctx.ai.saveThreadMessage('thread-42', {
'role': 'user',
'content': 'Customer cannot reset password',
});
final thread = await ctx.ai.loadThreadMessages('thread-42');
final events = await ctx.ai.loadRunEvents('run-id');
Production note #
Flint uses auto-configured AI memory and repository stores by default:
- when the database is connected, DB-backed stores are used
- when it is not connected, Flint falls back to in-memory stores with warnings
For production, connect the database or provide explicit shared stores so runs, traces, threads, and memory survive restarts and work across workers.
Built-in chat providers #
OpenAiChatProviderAnthropicChatProviderGeminiChatProvider
These live under ai and use the shared AI provider abstractions. There is no separate public agent namespace anymore.
End-to-end example #
class SummarizeTicketTool extends AiTool {
@override
String get name => 'support.summarize';
@override
String get description => 'Formats a support summary.';
@override
bool get enabledByDefault => true;
@override
Future<Map<String, dynamic>> execute(AiToolContext context) async {
final issue = context.arguments['issue']?.toString() ?? '';
return {'summary': 'Customer issue: $issue'};
}
}
class SupportAgent extends AiAgent {
@override
String get name => 'support_agent';
@override
Future<AiPlan> plan(AiRunContext context) async {
return AiPlan(
steps: [
AiPlanStep(
id: 'summarize',
type: 'tool',
description: 'Create a support summary',
toolName: 'support.summarize',
arguments: {'issue': context.goal.input['issue']},
),
],
);
}
@override
Future<Map<String, dynamic>> synthesize(AiRunContext context) async {
return {
'summary': (context.state['summarize'] as Map)['summary'],
'events': context.run.events.map((event) => event.toMap()).toList(),
};
}
}
final app = Flint();
app.ai.registerTool(SummarizeTicketTool());
app.post('/ai/support', (Context ctx) async {
final body = await ctx.req.json();
final threadId = body['threadId']?.toString() ?? 'support-thread';
await ctx.ai.saveThreadMessage(threadId, {
'role': 'user',
'content': body['issue'],
});
final run = await ctx.ai.run(
agent: SupportAgent(),
goal: AiGoal(
task: 'Resolve support request',
input: {'issue': body['issue']},
),
userId: 'support-user',
threadId: threadId,
context: ctx,
);
return ctx.res?.json({
'run': run.toMap(),
'thread': await ctx.ai.loadThreadMessages(threadId),
'events': await ctx.ai.loadRunEvents(run.run.id),
});
});
The sample app also includes real agent endpoints:
POST /ai-demo/supportGET /ai-demo/reportingPOST /ai-demo/content-email
Swagger UI (Auto Docs) #
Enable docs and visit:
http://localhost:3000/docs
Flint parses annotations above your routes:
/// @summary Create a new user
/// @auth bearer
/// @response 201 Created
/// @query page integer optional Page
/// @body {"name": "string", "email": "string"}
app.post('/users', controller.create);
HTTP QUERY routes are preserved in generated docs as Flint vendor
extensions, because OpenAPI 3.0 does not define a standard query operation
key. Flint does not map them to GET or POST; it emits x-http-method: QUERY and a top-level x-flint-query-routes section so tools can discover the
route without changing its semantics.
Database #
Configure .env:
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_NAME=flint
DB_USER=root
DB_PASSWORD=secret
DB_SECURE=false
Auto‑connect runs on server start. You can disable it and call DB.connect() manually.
final app = Flint(autoConnectDb: false);
try {
await DB.connect(
database: env('DB_NAME', 'app_db'),
host: env('DB_HOST', 'localhost'),
port: env('DB_PORT', 3306),
username: env('DB_USER', 'root'),
password: env('DB_PASSWORD', ''),
);
} catch (e) {
Log.debug('Database connection failed: $e');
}
Logging #
By default, Flint logs to console and does not create log files.
Use .env to control logging:
LOG_ENABLED=true
LOG_TO_CONSOLE=true
LOG_TO_FILE=false
LOG_DIR=logs
LOG_LEVEL=debug
LOG_TO_FILE=falseprevents creating log files on user systems.- Set
LOG_TO_FILE=trueonly when you want persisted log files.
CLI #
flint create my_app
flint run
flint build
flint docs:generate
flint make:model User
flint make:controller UserController
flint make:middleware AuthMiddleware
Contributing #
Contributions are welcome. Open issues, suggest improvements, or submit PRs.
License #
MIT