chase 0.1.2 copy "chase: ^0.1.2" to clipboard
chase: ^0.1.2 copied to clipboard

A fast, lightweight web framework for Dart. Features trie-based routing, 18+ middleware, WebSocket/SSE support, and schema validation.

Chase Logo

A fast, lightweight web framework for Dart.

Dart License: MIT

English ζ—₯本θͺž

Features #

  • πŸš€ Fast - Trie-based router for optimal performance
  • πŸͺΆ Lightweight - Minimal dependencies, small footprint
  • 🧩 Middleware - 18+ built-in middleware, easy to extend
  • πŸ”Œ Plugin System - Extend functionality with plugins
  • 🌐 Real-time - WebSocket, SSE, and streaming support
  • βœ… Validation - Schema-based request validation
  • 🌍 i18n - Built-in internationalization
  • πŸ§ͺ Testing - First-class testing utilities

Performance #

Benchmark Results

View detailed benchmarks

Table of Contents #

Installation #

dependencies:
  chase: ^0.1.2

Quick Start #

import 'package:chase/chase.dart';

void main() async {
  final app = Chase();

  // Simple string response
  app.get('/').handle((ctx) => 'Hello, World!');

  // JSON response (return Map directly)
  app.get('/hello/:name').handle((ctx) {
    final name = ctx.req.param('name');
    return {'message': 'Hello, $name!'};
  });

  // With status code (Response fluent API)
  app.post('/users').handle((ctx) async {
    final body = await ctx.req.json();
    return Response.created().json({'id': 1, ...body});
  });

  // Response object for full control
  app.get('/users/:id').handle((ctx) {
    return Response.ok().json({'id': ctx.req.param('id'), 'name': 'John'});
  });

  await app.start(port: 6060);
}

Routing #

Basic Routes #

final app = Chase();

// HTTP methods
app.get('/users').handle((ctx) => {'users': []});
app.post('/users').handle(createUser);
app.put('/users/:id').handle(updateUser);
app.patch('/users/:id').handle(patchUser);
app.delete('/users/:id').handle(deleteUser);
app.head('/users/:id').handle(checkUser);
app.options('/users').handle(corsHandler);

// Custom method
app.route('CUSTOM', '/any').handle((ctx) => 'Custom method');

Route Parameters #

// Single parameter
app.get('/users/:id').handle((ctx) {
  final id = ctx.req.param('id');
  return {'id': id};
});

// Multiple parameters
app.get('/users/:userId/posts/:postId').handle((ctx) {
  final userId = ctx.req.param('userId');
  final postId = ctx.req.param('postId');
  return {'userId': userId, 'postId': postId};
});

// Wildcard (catch-all)
app.get('/files/*path').handle((ctx) {
  final path = ctx.req.param('path');  // e.g., "images/photo.jpg"
  return 'File: $path';
});

// Optional parameter
app.get('/users/:id?').handle((ctx) {
  final id = ctx.req.param('id');  // null if not provided
  // Matches both /users and /users/123
});

// Optional with other parameters
app.get('/posts/:postId/comments/:commentId?').handle((ctx) {
  final postId = ctx.req.param('postId');      // Required
  final commentId = ctx.req.param('commentId'); // Optional
  // Matches /posts/1/comments and /posts/1/comments/2
});

Query Parameters #

app.get('/search').handle((ctx) {
  final query = ctx.req.query('q');           // Single value
  final tags = ctx.req.queryAll('tag');       // Multiple values
  final queries = ctx.req.queries;            // All as Map
  return {'query': query, 'tags': tags};
});

Multiple Paths #

Register the same handler for multiple paths:

// Same handler for multiple paths
app.get(['/hello', '/ja/hello']).handle((ctx) {
  return 'Hello!';
});

// Works with all HTTP methods
app.post(['/submit', '/api/submit']).handle(submitHandler);
app.put(['/update', '/api/update']).handle(updateHandler);

// With middleware
app.get(['/a', '/b', '/c'])
  .use(AuthMiddleware())
  .handle(handler);

// With path parameters
app.get(['/users/:id', '/members/:id']).handle((ctx) {
  final id = ctx.req.param('id');
  return {'id': id};
});

// all() and on() also support multiple paths
app.all(['/any', '/v1/any']).handle(anyHandler);
app.on(['GET', 'POST'], ['/form', '/api/form']).handle(formHandler);

Middleware #

Using Middleware #

// Global middleware
app.use(ExceptionHandler());
app.use(Logger());

// Multiple at once
app.useAll([Cors(), Compress()]);

// Route-specific
app.get('/admin')
  .use(BearerAuth(token: 'secret'))
  .handle(adminHandler);

// Chain multiple
app.post('/api/data')
  .use(RateLimit(limit: 100))
  .use(BodyLimit(maxSize: 1024 * 1024))
  .handle(dataHandler);

Built-in Middleware #

Middleware Description
Authentication
BasicAuth HTTP Basic authentication
BearerAuth Bearer token authentication
JwtAuth JWT authentication with claims
Security
Cors Cross-Origin Resource Sharing
Csrf CSRF protection with tokens
SecureHeaders Security headers (CSP, HSTS, etc.)
RateLimit Request rate limiting
BodyLimit Request body size limit
IpRestriction IP-based access control
Performance
Compress Gzip/Deflate compression
CacheControl Cache-Control headers
ETag Entity tag for caching
Timeout Request timeout handling
Timing Server-Timing headers for performance monitoring
Utilities
Logger Request/response logging
RequestId Unique request ID generation
ExceptionHandler Error handling
Session Session management
I18n Internationalization
Validator Request validation
Proxy HTTP proxy
StaticFileHandler Static file serving
PrettyJson Format JSON with indentation
TrailingSlash Normalize trailing slashes (trim/append)

Custom Middleware #

class TimingMiddleware implements Middleware {
  @override
  FutureOr<void> handle(Context ctx, NextFunction next) async {
    final sw = Stopwatch()..start();
    await next();
    print('${ctx.req.method} ${ctx.req.path} - ${sw.elapsedMilliseconds}ms');
  }
}

app.use(TimingMiddleware());

Request & Response #

Request #

app.post('/users').handle((ctx) async {
  // Body
  final json = await ctx.req.json();           // JSON body
  final text = await ctx.req.text();           // Raw text
  final form = await ctx.req.formData();       // Form data
  final multipart = await ctx.req.multipart(); // Multipart

  // Headers
  final contentType = ctx.req.header('content-type');
  final headers = ctx.req.headers;

  // Request info
  final method = ctx.req.method;
  final path = ctx.req.path;
  final url = ctx.req.url;

  return {'received': json};
});

Content Negotiation

app.get('/data').handle((ctx) {
  // Accept header negotiation
  final type = ctx.req.accepts(['json', 'html', 'xml'], defaultValue: 'json');

  if (type == 'html') {
    return Response.html('<h1>Data</h1>');
  }
  return {'data': 'value'};
});

// Language negotiation
final lang = ctx.req.acceptsLanguages(['en', 'ja', 'zh'], defaultValue: 'en');

// Encoding negotiation
final encoding = ctx.req.acceptsEncodings(['gzip', 'br'], defaultValue: 'identity');

Connection Info

app.get('/info').handle((ctx) {
  final info = ctx.req.connInfo;

  return {
    'remoteAddress': info.remote.address,  // Client IP
    'remotePort': info.remote.port,        // Client port
    'addressType': info.remote.addressType?.name,  // 'ipv4' or 'ipv6'
    'localPort': info.local.port,          // Server port
  };
});

// Shorthand accessors also available
final ip = ctx.req.ip;              // With X-Forwarded-For support
final addr = ctx.req.remoteAddress; // Direct connection IP

Response #

Chase supports multiple response styles - from simple return values to Response objects.

// String β†’ text/plain
app.get('/text').handle((ctx) => 'Hello, World!');

// Map β†’ application/json
app.get('/json').handle((ctx) => {'message': 'Hello'});

// List β†’ application/json
app.get('/list').handle((ctx) => [1, 2, 3]);

// Response object β†’ full control
app.get('/custom').handle((ctx) => Response.ok().json({'status': 'success'}));

Response Fluent API

// JSON response with status
app.post('/users').handle((ctx) => Response.created().json({'id': 1}));

// With custom headers (chainable)
app.get('/data').handle((ctx) {
  return Response.ok()
    .header('X-Custom', 'value')
    .json({'data': 'value'});
});

// HTML response
app.get('/html').handle((ctx) => Response.html('<h1>Hello</h1>'));

// Text response
app.get('/text').handle((ctx) => Response.text('Hello, World!'));

// Redirect
app.get('/old').handle((ctx) => Response.redirect('/new'));

// Not found
app.get('/missing').handle((ctx) => Response.notFound('Resource not found'));

Response Class

// Success responses (2xx)
Response.ok().text('Hello')             // 200 text
Response.ok().json({'data': value})     // 200 JSON
Response.created().json({'id': 1})      // 201
Response.noContent()                    // 204
Response.accepted().json({'status': 'pending'}) // 202

// Redirects (3xx)
Response.movedPermanently('/new')       // 301
Response.redirect('/temp')              // 302
Response.seeOther('/other')             // 303
Response.temporaryRedirect('/temp')     // 307
Response.permanentRedirect('/new')      // 308

// Client errors (4xx)
Response.badRequest().json({'error': 'Invalid'})   // 400
Response.unauthorized()                             // 401
Response.forbidden()                                // 403
Response.notFound().json({'error': 'Not found'})   // 404
Response.conflict()                                 // 409
Response.unprocessableEntity().json({'errors': []}) // 422
Response.tooManyRequests()                          // 429

// Server errors (5xx)
Response.internalServerError()          // 500
Response.badGateway()                   // 502
Response.serviceUnavailable()           // 503

// Convenience factories (return Response directly)
Response.json({'key': 'value'}, status: 201)
Response.text('Hello', status: 200)
Response.html('<h1>Hello</h1>')

Low-Level Access (ctx.res)

For advanced use cases, you can still access the underlying HttpResponse:

app.get('/low-level').handle((ctx) async {
  // Direct header access
  ctx.res.headers.set('X-Custom', 'value');
  
  // Cookies
  ctx.res.cookie('session', 'abc123', maxAge: Duration(hours: 24));
  ctx.res.deleteCookie('session');
  
  // Status code
  ctx.res.statusCode = 200;
  
  // Write directly
  ctx.res.write('Hello');
  await ctx.res.close();
});

Validation #

chase provides a powerful schema-based validation system.

Schema Definition #

final userSchema = Schema({
  'name': V.isString().required().min(2).max(50),
  'email': V.isString().required().email(),
  'age': V.isInt().min(0).max(150),
  'role': V.isString().oneOf(['admin', 'user', 'guest']),
  'tags': V.list().min(1).max(10),
  'active': V.isBool().defaultValue(true),
});

Validator Middleware #

app.post('/users')
  .use(Validator(body: userSchema))
  .handle((ctx) {
    // Access validated & transformed data
    final data = ctx.validatedBody!;
    return Response.created().json({'created': data});
  });

Validate Query & Params #

final querySchema = Schema({
  'page': V.isInt().defaultValue(1).min(1),
  'limit': V.isInt().defaultValue(20).max(100),
  'sort': V.isString().oneOf(['asc', 'desc']).defaultValue('desc'),
});

final paramsSchema = Schema({
  'id': V.isInt().required().min(1),
});

app.get('/users/:id/posts')
  .use(Validator(query: querySchema, params: paramsSchema))
  .handle((ctx) {
    final page = ctx.validatedQuery!['page'];
    final userId = ctx.validatedParams!['id'];
    // ...
  });

Available Validators #

// Type validators
V.isString()     // String validation
V.isInt()        // Integer (auto-parses strings)
V.isDouble()     // Double/number
V.isBool()       // Boolean (accepts "true", "1", etc.)
V.list()         // Array/List
V.map()          // Object/Map
V.any()          // Any type

// String rules
V.isString()
  .required()                    // Must not be null or empty
  .min(5)                        // Minimum length
  .max(100)                      // Maximum length
  .length(10)                    // Exact length
  .email()                       // Email format
  .url()                         // URL format
  .pattern(RegExp(r'^\d+$'))     // Custom regex
  .oneOf(['a', 'b', 'c'])        // Allowed values

// Number rules
V.isInt()
  .required()
  .min(0)                        // Minimum value
  .max(100)                      // Maximum value

// Custom validation
V.isString().custom(
  (value) => value.startsWith('A'),
  message: 'Must start with A',
)

// Default values
V.isString().defaultValue('guest')

Manual Validation #

final schema = Schema({
  'email': V.isString().required().email(),
});

final result = schema.validate({'email': 'invalid'});
if (!result.isValid) {
  for (final error in result.errors) {
    print('${error.field}: ${error.message}');
  }
}

Route Groups #

// Using path()
final api = app.path('/api');
api.use(BearerAuth(token: 'secret'));
api.get('/users').handle(getUsers);
api.post('/users').handle(createUser);

// Using routes() callback
app.routes('/api/v1', (v1) {
  v1.use(Logger());
  
  v1.routes('/users', (users) {
    users.get('/').handle(listUsers);
    users.get('/:id').handle(getUser);
    users.post('/').handle(createUser);
  });
});

// Nested groups
final admin = app.path('/admin');
admin.use(JwtAuth(secret: 'secret'));

final adminUsers = admin.path('/users');
adminUsers.get('/').handle(listAdminUsers);

WebSocket #

app.get('/ws').handle((ctx) async {
  final ws = await ctx.req.upgrade();
  
  ws.onMessage((message) {
    print('Received: $message');
    ws.send('Echo: $message');
  });
  
  ws.onClose((code, reason) {
    print('Closed: $code $reason');
  });
  
  ws.onError((error) {
    print('Error: $error');
  });
});

Server-Sent Events #

app.get('/events').handle((ctx) {
  return streamSSE(ctx, (stream) async {
    // Send events
    await stream.writeSSE(SSEMessage(
      data: '{"count": 1}',
      event: 'update',
    ));
    
    await stream.writeSSE(SSEMessage(
      data: '{"count": 2}',
      event: 'update',
      id: '2',
    ));
    
    // Real-time updates
    for (var i = 0; i < 10; i++) {
      await stream.sleep(Duration(seconds: 1));
      await stream.writeSSE(SSEMessage(
        data: DateTime.now().toIso8601String(),
      ));
    }
  });
});

Streaming #

Chase provides Hono-style streaming helpers that return Response objects.

Text Streaming #

app.get('/stream').handle((ctx) {
  return streamText(ctx, (stream) async {
    for (var i = 0; i < 10; i++) {
      await stream.writeln('Line $i');
      await stream.sleep(Duration(milliseconds: 100));
    }
  });
});

Binary Streaming #

app.get('/download').handle((ctx) {
  return stream(ctx, (s) async {
    final file = File('large-file.zip');
    await s.pipe(file.openRead());
  }, headers: {
    'content-disposition': 'attachment; filename="file.zip"',
  });
});

Streaming with Abort Handling #

app.get('/long-stream').handle((ctx) {
  return streamText(ctx, (stream) async {
    stream.onAbort(() {
      print('Client disconnected');
    });
    
    while (!stream.isClosed) {
      await stream.writeln(DateTime.now().toIso8601String());
      await stream.sleep(Duration(seconds: 1));
    }
  });
});

Static Files #

// Basic usage
app.staticFiles('/static', './public');

// With options
app.staticFiles('/assets', './public', StaticOptions(
  maxAge: Duration(days: 365),
  etag: true,
  index: ['index.html'],
  dotFiles: DotFiles.ignore,
));

// Or use middleware directly
app.get('/files/*path')
  .use(StaticFileHandler('./uploads'))
  .handle((ctx) => ctx.res.notFound());

Session #

// Add session middleware
app.use(Session(
  store: MemorySessionStore(),
  cookieName: 'session_id',
  maxAge: Duration(hours: 24),
));

// Use sessions
app.post('/login').handle((ctx) async {
  final body = await ctx.req.json();
  ctx.session['userId'] = body['userId'];
  ctx.session['loggedIn'] = true;
  return {'success': true};
});

app.get('/profile').handle((ctx) {
  if (ctx.session['loggedIn'] != true) {
    return Response.unauthorized().json({'error': 'Not logged in'});
  }
  return {'userId': ctx.session['userId']};
});

app.post('/logout').handle((ctx) async {
  await ctx.destroySession();
  return {'success': true};
});

Internationalization #

Setup #

// Load translations
final translations = I18nTranslations.fromMap({
  'en': {
    'greeting': 'Hello',
    'welcome': 'Welcome, {name}!',
  },
  'ja': {
    'greeting': 'こんにけは',
    'welcome': 'γ‚ˆγ†γ“γγ€{name}さん!',
  },
});

// Add middleware
app.use(I18n(
  translations: translations,
  defaultLocale: 'en',
  supportedLocales: ['en', 'ja', 'ko'],
));

Usage #

app.get('/greeting').handle((ctx) {
  final t = ctx.t;  // Translation function
  
  return {
    'greeting': t('greeting'),
    'welcome': t('welcome', {'name': 'John'}),
    'locale': ctx.locale,
  };
});

Locale Detection #

Locale is detected in order:

  1. Query parameter: ?lang=ja
  2. Accept-Language header
  3. Default locale
// Force specific locale
app.get('/ja/greeting').handle((ctx) {
  ctx.setLocale('ja');
  return {'message': ctx.t('greeting')};
});

Testing #

chase provides comprehensive testing utilities.

TestClient #

import 'package:chase/chase.dart';
import 'package:chase/testing/testing.dart';
import 'package:test/test.dart';

void main() {
  late Chase app;
  late TestClient client;

  setUp(() async {
    app = Chase();
    app.get('/').handle((ctx) => 'Hello');
    app.post('/users').handle((ctx) async {
      final body = await ctx.req.json();
      return Response.created().json(body);
    });
    
    client = await TestClient.start(app);
  });

  tearDown(() => client.close());

  test('GET request', () async {
    final res = await client.get('/');
    expect(res, isOkResponse);
    expect(await res.body, 'Hello');
  });

  test('POST JSON', () async {
    final res = await client.postJson('/users', {'name': 'John'});
    expect(res, hasStatus(201));
    expect(await res.json, hasJsonPath('name', 'John'));
  });
}

Custom Matchers #

// Status matchers
expect(res, isOkResponse);           // 2xx
expect(res, isRedirectResponse);     // 3xx
expect(res, isClientErrorResponse);  // 4xx
expect(res, isServerErrorResponse);  // 5xx
expect(res, hasStatus(201));         // Exact status

// Header matchers
expect(res, hasHeader('content-type'));
expect(res, hasHeader('content-type', 'application/json'));
expect(res, hasHeader('content-type', contains('json')));
expect(res, hasContentType('application/json'));

// JSON matchers
final json = await res.json;
expect(json, hasJsonPath('user.name', 'John'));
expect(json, hasJsonPath('items', hasLength(3)));
expect(json, hasJsonPath('data.tags', ['a', 'b']));

// Cookie matchers
expect(res, hasCookie('session'));
expect(res, hasCookie('token', 'abc123'));

TestClient Extensions #

// Auth helper
final res = await client.getWithAuth('/profile', 'my-token');

// JSON POST helper
final res = await client.postJson('/users', {'name': 'John'});
final res = await client.postJson('/users', {'name': 'John'}, token: 'secret');

Unit Testing with TestContext #

test('middleware behavior', () async {
  final ctx = TestContext.get('/api/users', headers: {
    'Authorization': 'Bearer token123',
  });
  
  var nextCalled = false;
  await myMiddleware.handle(ctx, () async {
    nextCalled = true;
  });
  
  expect(nextCalled, isTrue);
  expect(ctx.res.statusCode, 200);
});

Plugins #

Using Plugins #

final app = Chase()
  ..plugin(HealthCheckPlugin())
  ..plugin(MetricsPlugin());

Creating Plugins #

class HealthCheckPlugin extends Plugin {
  @override
  String get name => 'health-check';

  @override
  void onInstall(Chase app) {
    app.get('/health').handle((ctx) {
      return {
        'status': 'healthy',
        'timestamp': DateTime.now().toIso8601String(),
      };
    });
  }

  @override
  Future<void> onStart(Chase app) async {
    print('Health check endpoint ready');
  }

  @override
  Future<void> onStop(Chase app) async {
    print('Shutting down health check');
  }
}

Context Store #

Share data between middleware and handlers:

// Middleware sets data
class AuthMiddleware implements Middleware {
  @override
  FutureOr<void> handle(Context ctx, NextFunction next) async {
    final user = await validateToken(ctx.req.header('Authorization'));
    ctx.set('user', user);
    ctx.set('requestId', generateId());
    await next();
  }
}

// Handler retrieves data
app.get('/profile').handle((ctx) {
  final user = ctx.get<User>('user');
  final requestId = ctx.get<String>('requestId');
  
  if (ctx.has('user')) {
    return {'user': user, 'requestId': requestId};
  }
  return Response.unauthorized().json({'error': 'Unauthorized'});
});

Method Override #

HTML forms only support GET and POST. Method Override allows forms to simulate PUT, PATCH, and DELETE requests.

// Enable method override (default: form field "_method")
final app = Chase()..methodOverride();

// Custom configuration
final app = Chase()
  ..methodOverride(
    form: '_method',            // Form field name (default)
    header: 'X-HTTP-Method',    // Header name
    query: '_method',           // Query parameter name
  );

// Now handles DELETE from form
app.delete('/posts/:id').handle((ctx) {
  return {'deleted': ctx.req.param('id')};
});
<form action="/posts/123" method="POST">
  <input type="hidden" name="_method" value="DELETE" />
  <button type="submit">Delete</button>
</form>

Server Configuration #

// Development mode (prints routes)
final app = Chase(dev: true);

// Custom router
final app = Chase(router: TrieRouter());  // Default, trie-based (fast)
final app2 = Chase(router: RegexRouter()); // Regex-based (flexible)

// Start options
await app.start(port: 6060);
await app.start(host: '0.0.0.0', port: 8080);
await app.start(shared: true);  // Multi-isolate support

// Server info
print(app.isRunning);
print(app.server?.port);

// Graceful shutdown
await app.stop();
await app.stop(force: true);

Convenience Setup #

// Add common middleware stack
final app = Chase()..withDefaults();

// Equivalent to:
final app = Chase()
  ..use(ExceptionHandler())
  ..use(Logger());

Examples #

See the example directory for more examples:

License #

MIT License - see LICENSE file for details.

0
likes
140
points
160
downloads

Publisher

unverified uploader

Weekly Downloads

A fast, lightweight web framework for Dart. Features trie-based routing, 18+ middleware, WebSocket/SSE support, and schema validation.

Repository (GitHub)
View/report issues

Topics

#web #server #http #framework #fast

Documentation

API reference

License

MIT (license)

Dependencies

crypto, dart_jsonwebtoken, meta, path, test, yaml, zlogger

More

Packages that depend on chase