Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spry v6.0.0 #145

Merged
merged 26 commits into from
Dec 30, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 2 additions & 24 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,3 @@
## v5.3.0-WIP
## v6.0.0-dev.1

- **deps**: Adapt to [routing kit](https://github.com/medz/routingkit) v4.1.0
- **feat**: `Event` support get request.
- **feat**: `Event` support get RAW request object.
- **feat**: `Event` support get application.
- **refactor**: `createEvent` has a new parameter change

## v5.2.0

- Fixed incorrect overwrite of `X-Powered-By` header.
- Support use single params of key using `useParam` fn.
- Fixed IO routes not match.

## 5.1.0

Adapt to `routingkit` v4.0.0 version

## 5.0.2

- Remove `http_parser` package.

## 5.0.1

- Change the `meta` package version to `>=1.12.0 <2.0.0`
Automatic adaptation allows platform.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -13,8 +13,7 @@ import 'package:spry/spry.dart';

main() {
final app = createSpry();

app.get("hello", (event) => "Hello, Spry!");
app.get('/hello', (event) => '🎉 Welcome to Spry!');
}
```

32 changes: 3 additions & 29 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,30 +1,4 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.

include: package:lints/recommended.yaml

# Uncomment the following section to specify additional rules.

# linter:
# rules:
# - camel_case_types

# analyzer:
# exclude:
# - path/to/excluded/files/**

# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints

# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
analyzer:
enable-experiment:
- macros
Binary file modified bun.lockb
Binary file not shown.
3 changes: 0 additions & 3 deletions demo.dart

This file was deleted.

41 changes: 39 additions & 2 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
# Spry Examples

- [Bun.js] example see 👉 https://spry.fun/adapters/bun
- [`dart:io`] example see 👉 https://spry.fun/adapters/io
## [Server Example](server.dart)

```dart
import 'package:spry/server.dart';

Future<void> main() async {
final server = serve(
hostname: 'localhost',
port: 3000,
fetch: (request, _) {
return Response.fromString("Hey, I'm Spry cross server!");
},
);
await server.ready();
print('🎉 Server listen on ${server.url}');
}

```

## [Spry Application Example](app.dart)

```dart
import 'package:spry/spry.dart';

Future<void> main() async {
final app = createSpry();

app.all('/', (_) => '🎉 Welcome to Spry!');
app.get('/say/:name', (event) {
return 'Your name is ${event.params['name']}';
});

final server = app.serve(port: 3000);
await server.ready();

print('🎉 Spry Server listen on ${server.url}');
}

```
26 changes: 14 additions & 12 deletions example/app.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import 'package:spry/spry.dart';
import 'package:spry/ws.dart';

final app = createSpry()
..get('/', (event) => 'Hello Spry!')
..ws('/ws', defineHooks(message: (peer, message) {
final text = message.text();
print('[WS] message: $text');
if (text.toLowerCase().contains('ping')) {
peer.send(Message.text('pong'));
}
}))
..all('/**', (event) => 'Fallback');

Future<void> main() async {
final app = createSpry();

app.all('/', (_) => '🎉 Welcome to Spry!');
app.get('/say/:name', (event) {
return 'Your name is ${event.params['name']}';
});

final server = app.serve(port: 3000);
await server.ready();

print('🎉 Spry Server listen on ${server.url}');
}
10 changes: 0 additions & 10 deletions example/bun.dart

This file was deleted.

14 changes: 0 additions & 14 deletions example/io.dart

This file was deleted.

13 changes: 13 additions & 0 deletions example/server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:spry/server.dart';

Future<void> main() async {
final server = serve(
hostname: 'localhost',
port: 3000,
fetch: (request, _) {
return Response.fromString("Hey, I'm Spry cross server!");
},
);
await server.ready();
print('🎉 Server listen on ${server.url}');
}
224 changes: 0 additions & 224 deletions lib/bun.dart

This file was deleted.

408 changes: 0 additions & 408 deletions lib/cookie.dart

This file was deleted.

139 changes: 0 additions & 139 deletions lib/io.dart

This file was deleted.

9 changes: 0 additions & 9 deletions lib/plain.dart

This file was deleted.

10 changes: 10 additions & 0 deletions lib/server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export 'src/http/cross_file.dart';
export 'src/http/formdata.dart';
export 'src/http/headers.dart';
export 'src/http/http_message.dart';
export 'src/http/request.dart';
export 'src/http/response.dart';
export 'src/http/url_search_params.dart';

export 'src/server/serve.dart';
export 'src/server/server.dart';
22 changes: 5 additions & 17 deletions lib/spry.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,5 @@
export 'src/types.dart';
export 'src/next.dart';
export 'src/routes+all.dart';
export 'src/routes+group.dart';
export 'src/routes+methods.dart';

export 'src/http/headers.dart';
export 'src/http/http_message.dart';
export 'src/http/http_status_reason_phrase.dart';
export 'src/http/request.dart';
export 'src/http/response.dart';

export 'src/utils/create_error.dart';
export 'src/utils/create_event.dart';
export 'src/utils/create_spry.dart';
export 'src/utils/request_utils.dart';
export 'src/utils/to_handler.dart';
export 'server.dart';
export 'src/event.dart';
export 'src/locals.dart';
export 'src/spry.dart';
export 'src/responder/responder.dart';
3 changes: 0 additions & 3 deletions lib/src/_constants.dart

This file was deleted.

16 changes: 16 additions & 0 deletions lib/src/_io_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'dart:io';

import 'http/headers.dart';

extension ToSpryHeaders on HttpHeaders {
Headers toSpryHeaders() {
final headers = Headers();
forEach((name, values) {
for (final value in values) {
headers.add(name, value);
}
});

return headers;
}
}
45 changes: 45 additions & 0 deletions lib/src/_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'dart:typed_data';

import 'package:slugid/slugid.dart';

import 'event.dart';
import 'http/formdata.dart';
import 'http/response.dart';
import 'responder/_resolve.dart';
import 'responder/responder.dart';

String createUniqueID() => Slugid.nice().toString();

Future<Response> responder(Event event, Object? data) async {
return switch (data) {
Future future => responder(event, await future),
Response response => response,
Responder(:final respond) => respond(event),
Stream<Uint8List> stream => Response(stream),
Stream<List<int>> stream => Response(stream.map(Uint8List.fromList)),
Stream<Iterable<int>> stream =>
Response(stream.map((event) => Uint8List.fromList(event.toList()))),
Uint8List bytes => Response.fromBytes(bytes),
String value => Response.fromString(value),
FormData form => Response.fromFormData(form),
TypedData data => Response.fromBytes(data.buffer.asUint8List()),
ByteBuffer buffer => Response.fromBytes(buffer.asUint8List()),
Object data => switch (await resove(event, data)) {
Response response => response,
_ => tryCreateJsonResponse(data),
},
_ => Response(null, status: 404),
};
}

Response tryCreateJsonResponse(Object data) {
try {
return Response.fromJson(data);
} catch (_) {
try {
return Response.fromJson((data as dynamic).toJson());
} catch (_) {}

rethrow;
}
}
62 changes: 62 additions & 0 deletions lib/src/event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'http/headers.dart';
import 'http/request.dart';
import 'http/url_search_params.dart';
import 'locals.dart';
import 'spry.dart';

/// Spry request event.
class Event {
Event({
required this.app,
required this.request,
required this.id,
this.address,
Locals? locals,
Map<String, String>? params,
}) : locals = locals ?? Locals(),
params = params ?? {};

/// Returns the spry application instane.
final Spry app;

/// Returns the request locals.
final Locals locals;

/// Returns the event request object.
final Request request;

/// Returns the request matched route params.
final Map<String, String> params;

/// Returns removed remote address.
final String? address;

/// The request event unique ID.
final String id;

/// Returns the request heanders.
Headers get headers => request.headers;

/// Returns the request method.
String get method => request.method;

/// Returns the request url.
Uri get url => request.url;

/// Returns the request url path without query.
String get pathname => url.path;

/// Returns the request url path with query.
String get path {
if (url.hasQuery) {
return '$pathname?${query.toQueryString()}';
}

return pathname;
}

/// Returns the request query parmas.
URLSearchParams get query => _queryCache ??=
url.hasQuery ? URLSearchParams() : URLSearchParams.parse(url.query);
URLSearchParams? _queryCache;
}
34 changes: 34 additions & 0 deletions lib/src/http/_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
String normalizeHeaderName(String name) => name.trim().toLowerCase();

String? getHeaderSubParam(String? contentType, String name) {
if (contentType == null || contentType.isEmpty) {
return null;
}

final normalizedName = normalizeHeaderName(name);
for (final part in contentType.split(';')) {
final [name, ...values] = part.trim().split('=');
if (normalizeHeaderName(name) == normalizedName) {
return values.join('=').trim().escaped;
}
}

return null;
}

T tryRun<T>(T Function(T) fn, T value) {
try {
return fn(value);
} catch (_) {
return value;
}
}

extension on String {
/// Returns the string ' and " escaped for start and end.
String get escaped {
if (startsWith('"') || startsWith("'")) return substring(1).escaped;
if (endsWith('"') || endsWith("'")) return substring(0, length - 1).escaped;
return this;
}
}
7 changes: 7 additions & 0 deletions lib/src/http/cross_file.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:cross_file/cross_file.dart' show XFile;

/// A CrossFile is a cross-platform, simplified File abstraction.
///
/// > ![NOTE]
/// > expose from `package:cross_file/cross_file.dart`
typedef CrossFile = XFile;
270 changes: 270 additions & 0 deletions lib/src/http/formdata.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:mime/mime.dart' show MimeMultipartTransformer;
import 'package:path/path.dart' show basename;

import '_utils.dart';
import 'cross_file.dart';
import 'headers.dart';

const _lineTerminatorStr = '\r\n';

/// [FormData] entry base class.
///
/// * [FormDataString] - Creates a [String] value form-data field.
/// * [FormDataFile] - Creates a [CrossFile] value form-data field.
sealed class FormDataEntry {
/// {@template spry.formdata.entry.constructor}
/// Creates a new form-data entry.
///
/// * [name]: The form-data field name.
/// {@endtemplate}
const FormDataEntry(this.name);

/// The form-data field name.
final String name;
}

/// [FormData] string field entry.
///
/// Example:
/// {@template spry.formdata.example.str}
/// ```dart
/// final form = FormData([
/// FormDataString('foo', 'bar'),
/// ]);
///
/// // Append a new field.
/// form.add(FormDataString('a', 'b'));
/// ```
/// {@endtemplate}
final class FormDataString extends FormDataEntry {
/// {@macro spry.formdata.entry.constructor}
/// * [value]: The field string value.
///
/// Example:
/// {@macro spry.formdata.example.str}
const FormDataString(super.name, this.value);

/// The field string value.
final String value;
}

/// [FormData] file field entry.
///
/// Example:
/// {@template spry.formdata.example.file}
/// ```dart
/// final form = FormData([
/// FormDataFile('brand', '/images/logo/spry.web'),
/// ]);
/// ```
/// {@endtemplate}
final class FormDataFile extends FormDataEntry implements CrossFile {
/// {@macro spry.formdata.entry.constructor}
/// {@template spry.formdata.file.path}
/// * [path]: The file path.
/// {@endtemplate}
/// {@template spry.formdata.file.params}
/// * [mimeType]: The file mime-type.
/// * [length]: The file length in bytes.
/// * [lastModified]: The file last modified time.
/// {@endtemplate}
/// {@template spry.formdata.file.bytes}
/// * [bytes]: The file contents of bytes.
/// {@endtemplate}
///
/// Example:
/// {@macro spry.formdata.example.file}
FormDataFile(
super.name,
String path, {
String? mimeType,
int? length,
Uint8List? bytes,
DateTime? lastModified,
}) : _file = CrossFile(path,
name: basename(path),
mimeType: mimeType,
length: length,
bytes: bytes,
lastModified: lastModified);

/// {@macro spry.formdata.entry.constructor}
/// {@macro spry.formdata.file.bytes}
/// {@macro spry.formdata.file.path}
/// {@macro spry.formdata.file.params}
FormDataFile.fromData(
super.name,
Uint8List bytes, {
String? mimeType,
int? length,
String? path,
DateTime? lastModified,
}) : _file = CrossFile.fromData(
bytes,
name: path != null ? basename(path) : name,
path: path,
mimeType: mimeType,
length: length,
lastModified: lastModified,
);

/// {@macro spry.formdata.entry.constructor}
/// * [file]: The form-data field value file.
FormDataFile.fromFile(super.name, CrossFile file) : _file = file;

final CrossFile _file;

@override
Future<DateTime> lastModified() => _file.lastModified();

@override
Future<int> length() => _file.length();

@override
String? get mimeType => _file.mimeType;

@override
Stream<Uint8List> openRead([int? start, int? end]) =>
_file.openRead(start, end);

@override
String get path => _file.path;

@override
Future<Uint8List> readAsBytes() => _file.readAsBytes();

@override
Future<String> readAsString({Encoding encoding = utf8}) =>
_file.readAsString(encoding: encoding);

@override
Future<void> saveTo(String path) => _file.saveTo(path);
}

/// Form-data type.
///
/// The type is form-data fields container, impl of [List<FormDataEntry>].
extension type FormData._(List<FormDataEntry> entries)
implements List<FormDataEntry> {
/// Creates a new [FormData].
///
/// * [init]: the init fields.
///
/// Only string parts example:
/// {@macro spry.formdata.example.str}
///
/// Only files example:
/// {@macro spry.formdata.example.file}
///
/// Mixed example:
/// ```dart
/// final form = FormData([
/// FormDataString('a', 'b'),
/// FormDataFile('b', 'demo.mp4'),
/// ]);
/// ```
factory FormData([Iterable<FormDataEntry>? init]) {
final form = FormData._([]);
if (init != null && init.isNotEmpty) {
for (final entry in init) {
form.add(entry);
}
}

return form;
}

/// Encode a [FormData] to [Stream].
///
/// * [boundary]: Boundary string used to construct form data binary encoding.
Stream<Uint8List> toStream(String boundary) async* {
final separator = utf8.encode('--$boundary$_lineTerminatorStr');
for (final entry in this) {
yield separator;
yield* switch (entry) {
FormDataString entry => _createStringEntryStream(entry),
FormDataFile file => _createFileEntryStream(file),
};
}

yield utf8.encode('--$boundary--$_lineTerminatorStr');
}

/// Parse a [stream] and returns [FormData].
///
/// - [boundary]: The boundary string contained in the form data binary.
/// - [stream]: Form data binary stream.
///
/// > [!NOTE]
/// >
/// > Usually, the boundary can be obtained from the header `contents-type`.
static Future<FormData> parse({
required String boundary,
required Stream<Uint8List> stream,
}) async {
final form = FormData();
final transformer = MimeMultipartTransformer(boundary).bind(stream);
await for (final part in transformer) {
final headers = Headers(part.headers);
final disposition = headers.get('content-disposition');
final name = getHeaderSubParam(disposition, 'name');
if (name == null) continue;

final bytes = <int>[];
await for (final chunk in part) {
bytes.addAll(chunk);
}

final filename = getHeaderSubParam(disposition, 'filename');
if (filename != null) {
final contentType =
headers.get('content-type')?.split(';').firstOrNull?.trim();
final data = Uint8List.fromList(bytes);
form.add(FormDataFile.fromData(
name,
data,
mimeType: contentType,
length: data.lengthInBytes,
path: filename,
));
continue;
}

form.add(FormDataString(name, utf8.decode(bytes)));
}

return form;
}
}

Uint8List _createHeader(String name) {
return utf8.encode(
'Content-Disposition: form-data; name="${Uri.encodeComponent(name)}"');
}

Stream<Uint8List> _createStringEntryStream(FormDataString entry) async* {
yield _createHeader(entry.name);

final lineTerminator = utf8.encode(_lineTerminatorStr);
yield lineTerminator;
yield lineTerminator;
yield utf8.encode(entry.value);
yield lineTerminator;
}

Stream<Uint8List> _createFileEntryStream(FormDataFile file) async* {
yield _createHeader(file.name);
yield utf8.encode('; filename="${Uri.encodeComponent(basename(file.path))}"');

final lineTerminator = utf8.encode(_lineTerminatorStr);
yield lineTerminator;
yield utf8
.encode('Content-Type: ${file.mimeType ?? 'application/octet-stream'}');
yield lineTerminator;
yield lineTerminator;
yield await file.readAsBytes();
yield lineTerminator;
}
93 changes: 63 additions & 30 deletions lib/src/http/headers.dart
Original file line number Diff line number Diff line change
@@ -1,54 +1,87 @@
/// HTTP headers
extension type Headers._(List<(String, String)> _)
import '_utils.dart';

/// The HTTP [Headers].
///
/// {@template spry.http.headers.example}
/// ```dart
/// final headers = Headers({'a': '1'});
///
/// print(headers.get('a')); // Prints: 1
/// ```
/// {@endtemplate}
extension type Headers._(List<(String, String)> _headers)
implements Iterable<(String, String)> {
/// Creates a new headers.
/// Creates a new [Headers].
///
/// - [init]: The headers init map.
///
/// Example:
/// {@macro spry.http.headers.example}
factory Headers([Map<String, String>? init]) {
final inner = <(String, String)>[];
if (init?.isNotEmpty == true) {
inner.addAll(init!.entries.map((e) => (e.key, e.value)));
final headers = Headers._([]);
if (init != null && init.isNotEmpty) {
for (final MapEntry(:key, :value) in init.entries) {
headers.add(key, value);
}
}

return Headers._(inner);
return headers;
}

/// Check if a header name exists in the [Headers].
bool has(String name) {
final normalizedName = normalizeHeaderName(name);
for (final (name, _) in this) {
if (name == normalizedName) return true;
}
return false;
}

/// Gets a header value for [name].
/// Gets a first header value.
String? get(String name) {
final normalizedName = _normalizeHeaderName(name);
for (final (name, value) in _) {
if (_normalizeHeaderName(name) == normalizedName) {
return value;
}
final normalizedName = normalizeHeaderName(name);
for (final (name, value) in this) {
if (name == normalizedName) return value;
}

return null;
}

/// Gets a header values for [name].
Iterable<String> getAll(String name) {
return where(_createHeaderEqTest(name)).map((e) => e.$2);
/// Gets a header all values.
Iterable<String> getAll(String name) sync* {
final normalizedName = normalizeHeaderName(name);
for (final (name, value) in this) {
if (name == normalizedName) yield value;
}
}

/// Add/appent a new header.
/// Adds a new header key-value.
void add(String name, String value) {
_.add((name, value));
_headers.add((normalizeHeaderName(name), value));
}

/// Set/Reset a header.
/// Sets a header, If the name exists remove it.
void set(String name, String value) {
this
..remove(name)
..add(name, value);
final normalizedName = normalizeHeaderName(name);
_headers
..removeWhere((e) => e.$1 == normalizedName)
..add((normalizedName, value));
}

/// Remove a header.
void remove(String name) {
_.removeWhere(_createHeaderEqTest(name));
}
}
///
/// If [value] is provided, delete the exact match.
/// Otherwise, delete all values of [name].
void remove(String name, [String? value]) {
final normalizedName = normalizeHeaderName(name);
bool test((String, String) e) {
if (value != null) {
return e.$1 == normalizedName && e.$2 == value;
}

String _normalizeHeaderName(String name) => name.toLowerCase();
return e.$1 == normalizedName;
}

bool Function((String, String) _) _createHeaderEqTest(String name) {
final normalizedName = _normalizeHeaderName(name);
return (header) => _normalizeHeaderName(header.$1) == normalizedName;
_headers.removeWhere(test);
}
}
85 changes: 77 additions & 8 deletions lib/src/http/http_message.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,85 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import '_utils.dart';
import 'formdata.dart';
import 'headers.dart';
import 'url_search_params.dart';

/// HTTP message universal interface.
abstract interface class HttpMessage {
/// Returns the HTTP message encoding.
Encoding get encoding;
/// Abstract http message class.
abstract class HttpMessage extends Stream<Uint8List> {
/// Creates a new [Request]/[Response].
///
/// See:
/// - [Request]
/// - [Response]
HttpMessage({Headers? headers, Stream<Uint8List>? body})
: headers = headers ?? Headers(),
body = body ?? const Stream.empty();

/// Returns the HTTP message headers.
Headers get headers;
/// Returns the [Request]/[Response] headers.
final Headers headers;

/// Returns the HTTP message body stream.
Stream<Uint8List>? get body;
/// Returns the [Request]/[Response] body stream.
final Stream<Uint8List> body;

@override
StreamSubscription<Uint8List> listen(
void Function(Uint8List event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return body.listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
}

/// Read the body as bytes.
Future<Uint8List> readAsBytes() async {
final length = int.parse(headers.get('content-length') ?? '0');
final result = Uint8List(length);
await for (final chunk in this) {
result.addAll(chunk);
}

return result;
}

/// Read the body as string.
Future<String> readAsString() async {
final result = StringBuffer();
await for (final chunk in this) {
result.write(utf8.decode(chunk));
}

return result.toString();
}

/// Read the body as dynamic value with JSON decode.
Future<dynamic> readAsJson() async {
return jsonDecode(await readAsString());
}

/// Read the body as to [FormData].
Future<FormData> readAsFormData({
String? boundary,
}) {
final contentType = headers.get('content-type');
boundary ??= getHeaderSubParam(contentType, 'boundary');
if (boundary == null || boundary.isEmpty) {
throw StateError('invalid boundary');
}

return FormData.parse(boundary: boundary, stream: body);
}

/// Read the body as to [URLSearchParams].
Future<URLSearchParams> readAsUrlencoded() async {
return URLSearchParams.parse(await readAsString());
}
}
91 changes: 0 additions & 91 deletions lib/src/http/http_status_reason_phrase.dart

This file was deleted.

100 changes: 21 additions & 79 deletions lib/src/http/request.dart
Original file line number Diff line number Diff line change
@@ -1,87 +1,29 @@
import 'dart:convert';
import 'dart:typed_data';

import 'http_message.dart';
import 'headers.dart';

/// HTTP request.
abstract interface class Request implements HttpMessage {
/// Returns the request method.
String get method;

/// The requested URI for the request event.
///
/// If the request URI is absolute (e.g. 'https://www.example.com/foo') then
/// it is returned as-is. Otherwise, the returned URI is reconstructed by
/// using the request URI path (e.g. '/foo') and HTTP header fields.
///
/// To reconstruct the scheme, the 'X-Forwarded-Proto' header is used. If it
/// is not present then the socket type of the connection is used i.e. if
/// the connection is made through a [SecureSocket] then the scheme is
/// 'https', otherwise it is 'http'.
///
/// To reconstruct the host, the 'X-Forwarded-Host' header is used. If it is
/// not present then the 'Host' header is used. If neither is present then
/// the host name of the server is used.
Uri get uri;

factory Request({
required Headers headers,
class Request<T> extends HttpMessage {
/// Creates a new [Request] instance.
Request({
required String method,
required Uri uri,
Encoding? encoding,
Stream<Uint8List>? body,
}) {
return _RequestImpl(
method: method,
uri: uri,
headers: headers,
body: body,
encoding: switch (encoding) {
Encoding encoding => encoding,
_ => _getEncodingForTypes(headers.getAll('content-type')),
},
);
}

static Encoding _getEncodingForTypes(Iterable<String> types) {
for (final type in types) {
for (final param in type.split(';')) {
final kv = param.trim().toLowerCase().split('=');
if (kv.length == 2 && kv[0].trim() == 'charset') {
final encoding = Encoding.getByName(kv[1].trim());
if (encoding != null) {
return encoding;
}
}
}
}

return utf8;
}
}

class _RequestImpl implements Request {
const _RequestImpl({
this.body,
required this.encoding,
required this.headers,
required this.method,
required this.uri,
});

@override
final Stream<Uint8List>? body;

@override
final Encoding encoding;

@override
final Headers headers;
required this.url,
super.headers,
super.body,
required this.runtime,
}) : method = method.toUpperCase();

@override
/// Returns the request method.
///
/// > [!NOTE]
/// >
/// > The method value is upper-case.
final String method;

@override
final Uri uri;
/// Returns the request resolved url.
final Uri url;

/// Returns runtime platform native request value.
///
/// - If platform is dart, the [runtime] is `HttpRequest`
/// - If platform is Bun/Node/Deno, the [runtime] is web `Request`.
final T runtime;
}
141 changes: 60 additions & 81 deletions lib/src/http/response.dart
Original file line number Diff line number Diff line change
@@ -1,111 +1,90 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'http_message.dart';
import 'package:http_parser/http_parser.dart' show MediaType;

import '../_utils.dart';
import 'formdata.dart';
import 'headers.dart';
import 'http_status_reason_phrase.dart';
import 'http_message.dart';

/// HTTP Response.
abstract interface class Response implements HttpMessage {
/// Response status.
int get status;

/// Response status HTTP reason phrase.
String get statusText;

/// Creates a RAW stream response.
factory Response(
class Response extends HttpMessage {
/// Creates a new [Response] instance.
Response(
Stream<Uint8List>? body, {
int status,
String? statusText,
Headers? headers,
Encoding encoding,
}) = _ResponseImpl;
this.status = 200,
super.headers,
}) : super(body: body);

/// Creates a redirect response.
factory Response.redirect(
Uri location, {
int status = 307,
String? statusText,
/// Creates a new [Response] from string value.
factory Response.fromString(
String body, {
int status = 200,
Headers? headers,
Encoding encoding = utf8,
}) {
assert(status >= 300 && status <= 308,
'Redirect status must be between 300 and 308');

return Response(null,
status: status,
statusText: statusText,
headers: headers,
encoding: encoding)
..headers.set('Location', location.toString());
final bytes = utf8.encode(body);
return Response(Stream.value(bytes), status: status, headers: headers)
..headers.set('Content-Type', 'text/plain')
..headers.set('Content-Length', bytes.lengthInBytes.toString());
}

/// Creates a text response.
factory Response.text(
String body, {
/// Creates a new [Response] from bytes.
factory Response.fromBytes(
Uint8List body, {
int status = 200,
String? statusText,
Headers? headers,
Encoding encoding = utf8,
}) {
final bytes = switch (encoding.encode(body)) {
final Uint8List value => value,
final List<int> bytes => Uint8List.fromList(bytes),
};
final res = Response(Stream.value(body), status: status, headers: headers);
if (!res.headers.any((e) => e.$1 == 'content-type')) {
res.headers.set('content-type', 'application/octet-stream');
}
res.headers.set('content-length', body.lengthInBytes.toString());

return Response(Stream<Uint8List>.value(bytes),
status: status,
statusText: statusText,
headers: headers,
encoding: encoding)
..headers.set('Content-Length', bytes.lengthInBytes.toString())
..headers.set('Content-Type', 'text/plain; charset=${encoding.name}');
return res;
}

/// Creates a JSON response.
factory Response.json(
/// Creates a new [Response] from JSON object.
factory Response.fromJson(
Object? body, {
int status = 200,
String? statusText,
Headers? headers,
Encoding encoding = utf8,
}) {
return Response.text(json.encode(body),
status: status,
statusText: statusText,
headers: headers,
encoding: encoding)
..headers
.set('Content-Type', 'application/json; charset=${encoding.name}');
final bytes = utf8.encode(json.encode(body));
return Response(Stream.value(bytes), status: status, headers: headers)
..headers.set('content-type', 'application/json')
..headers.set('content-length', bytes.lengthInBytes.toString());
}
}

/// Internal response impl.
class _ResponseImpl implements Response {
_ResponseImpl(
this.body, {
this.status = 200,
String? statusText,
/// Creates a new [Response] from [FromData].
factory Response.fromFormData(
FormData body, {
int status = 200,
Headers? headers,
this.encoding = utf8,
}) : statusReasonPhrase = statusText,
headers = headers ?? Headers();

final String? statusReasonPhrase;

@override
final Stream<Uint8List>? body;

@override
final Encoding encoding;

@override
Headers headers;
}) {
final boundary = createUniqueID();
return Response(body.toStream(boundary), status: status, headers: headers)
..headers.set('content-type', 'multipart/form-data; boundary=$boundary');
}

@override
/// Returns the [Response] status code.
final int status;

@override
String get statusText => statusReasonPhrase ?? status.httpStatusReasonPhrase;
Headers get headers {
final headers = super.headers;
if (headers.has('Content-Type')) {
final contentType = MediaType.parse(headers.get('Content-Type')!);
if (!contentType.parameters.containsKey('charset')) {
final newType = contentType.change(
parameters: {'charset': 'utf-8'},
clearParameters: false,
);
headers.set('Content-Type', newType.toString());
}
}

return headers;
}
}
97 changes: 97 additions & 0 deletions lib/src/http/url_search_params.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import '_utils.dart';

/// URL search params.
extension type URLSearchParams._(List<(String, String)> _params)
implements Iterable<(String, String)> {
/// Creates a new [URLSearchParams] instance.
factory URLSearchParams([Map<String, String>? init]) {
final params = URLSearchParams._([]);
if (init != null && init.isNotEmpty) {
for (final MapEntry(:key, :value) in init.entries) {
params.add(key, value);
}
}

return params;
}

/// Parse a query string to [URLSearchParams].
factory URLSearchParams.parse(String query) {
final queryWithoutAnchor = query.split('#').first.trim();
final queryWithoutMark = queryWithoutAnchor.startsWith('?')
? queryWithoutAnchor.substring(1)
: queryWithoutAnchor;
final params = URLSearchParams();
for (final part in queryWithoutMark.trim().split('&')) {
final [name, ...values] = part.split('=');
final value = values.join('=').trim();
params.add(normalizeHeaderName(name), value);
}

return params;
}

/// Returns the query first value from [name].
String? get(String name) {
final normalizedName = normalizeHeaderName(name);
for (final (name, value) in this) {
if (normalizedName == name) return value;
}

return null;
}

/// Returns all query values from [name].
Iterable<String> getAll(String name) sync* {
final normalizedName = normalizeHeaderName(name);
for (final (name, value) in this) {
if (normalizedName == name) yield value;
}
}

/// Adds a new query param.
void add(String name, String value) {
_params.add((
normalizeHeaderName(name),
tryRun(Uri.decodeQueryComponent, value),
));
}

/// Sets a query key-value part.
///
/// if the [name] exists, remove it.
void set(String name, String value) {
final normalizedName = normalizeHeaderName(name);
_params
..removeWhere((e) => e.$1 == normalizedName)
..add((
normalizedName,
tryRun(Uri.decodeQueryComponent, value),
));
}

/// Remove a param.
void remove(String name, [String? value]) {
final normalizedName = normalizeHeaderName(name);
bool test((String, String) e) {
if (value != null) {
return e.$1 == normalizedName && e.$2 == value;
}

return e.$1 == normalizedName;
}

_params.removeWhere(test);
}

/// Serialize to query string.
String toQueryString() {
final params = <String, List<String>>{};
for (final (name, value) in this) {
final values = params[name] ?? [];
values.add(tryRun(Uri.encodeQueryComponent, value));
}

return Uri(queryParameters: params).query;
}
}
5 changes: 5 additions & 0 deletions lib/src/locals.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// Spry locals.
extension type Locals._(Map _) implements Map {
/// Creates a new locals.
factory Locals([Map? init]) => Locals._(init ?? {});
}
16 changes: 0 additions & 16 deletions lib/src/next.dart

This file was deleted.

3 changes: 3 additions & 0 deletions lib/src/responder/_resolve.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export '_resolve_stub.dart'
if (dart.library.js_interop) '_resolve_web.dart'
if (dart.library.io) '_resolve_io.dart';
18 changes: 18 additions & 0 deletions lib/src/responder/_resolve_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'dart:io';
import 'dart:typed_data';

import '../_io_utils.dart';
import '../event.dart';
import '../http/response.dart';

Future<Response?> resove(Event _, Object? data) async {
if (data is HttpClientResponse) {
return Response(
data.map(Uint8List.fromList),
status: data.statusCode,
headers: data.headers.toSpryHeaders(),
);
}

return null;
}
4 changes: 4 additions & 0 deletions lib/src/responder/_resolve_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import '../event.dart';
import '../http/response.dart';

Future<Response?> resove(Event event, Object? _) async => null;
20 changes: 20 additions & 0 deletions lib/src/responder/_resolve_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'dart:js_interop';

import 'package:web/web.dart' as web;

import '../event.dart';
import '../http/response.dart';
import '../server/runtime/js/_utils.dart';

Future<Response?> resove(Event _, Object? data) async {
final response = data as web.Response;
if (response.isA<web.Response>()) {
return Response(
response.body?.toDartStream(),
status: response.status,
headers: response.headers.toSpryHeaders(),
);
}

return null;
}
12 changes: 12 additions & 0 deletions lib/src/responder/responder.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'dart:async';

import '../event.dart';
import '../http/response.dart';

/// Can serve as an object interface for responder.
///
/// Usually used for object self creation of responses.
abstract interface class Responder {
/// Create the current object as a [Response].
FutureOr<Response> respond(Event event);
}
9 changes: 0 additions & 9 deletions lib/src/routes+all.dart

This file was deleted.

53 changes: 0 additions & 53 deletions lib/src/routes+group.dart

This file was deleted.

34 changes: 0 additions & 34 deletions lib/src/routes+methods.dart

This file was deleted.

81 changes: 81 additions & 0 deletions lib/src/server/runtime/dart_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'dart:async';
import 'dart:io';

import '../../_io_utils.dart';
import '../../http/request.dart';
import '../../http/response.dart';
import '../server.dart';

class RuntimeServer extends Server<HttpServer, HttpRequest> {
RuntimeServer(super.options) {
final completer = Completer<void>();
future = completer.future;

void handler(HttpRequest httpRequest) {
final request = _Request(httpRequest);
final response = httpRequest.response;
unawaited(Future.sync(() async {
final Response(:status, :headers, :body) = await fetch(request);
response.statusCode = status;
for (final (name, value) in headers) {
response.headers.add(name, value);
}
await response.addStream(body);
await response.close();
}));
}

completer.complete(Future.sync(() async {
runtime = await HttpServer.bind(
options.hostname ?? 'localhost',
options.port ?? 0,
shared: options.reusePort,
);
runtime.listen(handler);
}));
}

late final Future<void> future;

@override
late final HttpServer runtime;

@override
Future<void> ready() => future;

@override
Future<void> close({bool force = true}) async {
await runtime.close();
}

@override
String? get hostname => runtime.address.host;

@override
int? get port => runtime.port;

@override
String? remoteAddress(HttpRequest request) {
final info = request.connectionInfo;
if (info == null) {
return null;
}

final addr = info.remoteAddress.type == InternetAddressType.IPv6
? '[${info.remoteAddress.host}]'
: info.remoteAddress.host;

return '$addr:${info.remotePort}';
}
}

class _Request extends Request {
_Request(HttpRequest request)
: super(
method: request.method,
url: request.requestedUri,
headers: request.headers.toSpryHeaders(),
body: request,
runtime: request,
);
}
124 changes: 124 additions & 0 deletions lib/src/server/runtime/js/_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';

import 'package:web/web.dart' as web;

import '../../../http/headers.dart';
import '../../../http/request.dart';
import '../../../http/response.dart';

extension type _UnderlyingSource._(JSObject _) implements JSObject {
/// The type must set to `bytes`
external factory _UnderlyingSource({
JSFunction? start,
JSFunction? cancel,
String? type,
});
}

extension ToDartStream on web.ReadableStream {
Stream<Uint8List> toDartStream() async* {
final reader = getReader() as web.ReadableStreamDefaultReader;
try {
while (true) {
final result = await reader.read().toDart;
if (result.done) break;
if (result.value == null) continue;
yield (result.value as JSUint8Array).toDart;
}
} finally {
reader.releaseLock();
}
}
}

extension ToWebReadableStream on Stream<Uint8List> {
web.ReadableStream toWebReadableStream() {
late final StreamSubscription<Uint8List> subscription;

void start(web.ReadableStreamDefaultController controller) {
subscription = listen(
(chunk) {
controller.enqueue(chunk.toJS);
},
onError: (e) => controller.error(e.jsify()),
onDone: () => controller.close(),
);
}

void cancel() {
unawaited(subscription.cancel());
}

return web.ReadableStream(_UnderlyingSource(
type: 'bytes',
start: start.toJS,
cancel: cancel.toJS,
));
}
}

extension ToWebHeader on Headers {
web.Headers toWebHeaders() {
final headers = web.Headers();
for (final (name, value) in this) {
headers.append(name, value);
}
return headers;
}
}

extension ToSpryHeaders on web.Headers {
@JS('forEach')
external void _forEach(JSFunction fn);

Headers toSpryHeaders() {
final headers = Headers();
void fn(String value, String name) => headers.add(name, value);
_forEach(fn.toJS);

for (final cookie in getSetCookie().toDart) {
headers.add('Set-Cookie', cookie.toDart);
}

return headers;
}
}

extension ToWebRequest on Request {
web.Request toWebRequest() {
return web.Request(
url.toString().toJS,
web.RequestInit(
method: method,
headers: headers.toWebHeaders(),
body: toWebReadableStream(),
),
);
}
}

extension ToSpryRequest on web.Request {
Request toSpryRequest() {
return Request(
method: method,
url: Uri.parse(url),
headers: headers.toSpryHeaders(),
body: body?.toDartStream(),
runtime: this,
);
}
}

extension ToWebResponse on Response {
web.Response toWebResponse() {
return web.Response(
toWebReadableStream(),
web.ResponseInit(
status: status,
headers: headers.toWebHeaders(),
),
);
}
}
81 changes: 81 additions & 0 deletions lib/src/server/runtime/js/bun_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'dart:async';
import 'dart:js_interop';

import 'package:web/web.dart' as web;

import '../../server.dart';
import '_utils.dart';

extension type SocketAddress._(JSObject _) implements JSObject {
external String get address;
external int get port;
external String get family;
}

extension type BunServer._(JSObject _) implements JSObject {
external String get hostname;
external int get port;
external web.URL get url;
external void stop([bool closeActiveConnections]);
external SocketAddress requestIP(web.Request request);
}

extension type BunServe._(JSObject _) implements JSObject {
external factory BunServe({
String? hostname,
int? port,
bool? reusePort,
JSFunction fetch,
});
}

@JS('Bun')
extension type Bun._(JSAny _) {
external static BunServer serve(BunServe serve);
}

class RuntimeServer extends Server<BunServer, web.Request> {
RuntimeServer(super.options) {
JSPromise<web.Response> handler(web.Request request) {
return fetch(request.toSpryRequest())
.then((response) => response.toWebResponse())
.toJS;
}

runtime = Bun.serve(BunServe(
fetch: handler.toJS,
hostname: options.hostname,
port: options.port,
reusePort: options.reusePort,
));
}

late final Future<void> future;

@override
late final BunServer runtime;

@override
Future<void> ready() async {}

@override
Future<void> close({bool force = false}) async {
runtime.stop(force);
}

@override
String? get hostname => runtime.hostname;

@override
int? get port => runtime.port;

@override
String get url => runtime.url.toString();

@override
String? remoteAddress(web.Request request) {
final addr = runtime.requestIP(request);
final hostname = addr.family == 'IPv6' ? '[${addr.address}]' : addr.address;
return '$hostname:${addr.port}';
}
}
92 changes: 92 additions & 0 deletions lib/src/server/runtime/js/deno_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'dart:async';
import 'dart:js_interop';

import 'package:web/web.dart' as web;

import '../../server.dart';
import '_utils.dart';

extension type ServeTcpOptions._(JSObject _) implements JSObject {
external factory ServeTcpOptions({
int? port,
String? hostname,
bool? reusePort,
JSFunction? onListen,
});
}

extension type Addr._(JSObject _) implements JSObject {
external String get hostname;
external int get port;
}

extension type DenoServer._(JSObject _) implements JSObject {
external JSPromise shutdown();
external Addr get addr;
}

extension type ServeHandlerInfo._(JSObject _) implements JSObject {
external Addr get remoteAddr;
}

extension type Deno._(JSAny _) {
external static DenoServer serve(ServeTcpOptions options, JSFunction handler);
}

extension on web.Request {
external Addr remoteAddr;
}

class RuntimeServer extends Server<DenoServer, web.Request> {
RuntimeServer(super.options) {
final completer = Completer<void>();

void ready() => completer.complete();
JSPromise<web.Response> handler(
web.Request request,
ServeHandlerInfo info,
) {
request.remoteAddr = info.remoteAddr;
return fetch(request.toSpryRequest())
.then((response) => response.toWebResponse())
.toJS;
}

final denoServeOptions = ServeTcpOptions(
hostname: options.hostname,
port: options.port,
reusePort: options.reusePort,
onListen: ready.toJS,
);

future = completer.future;
runtime = Deno.serve(denoServeOptions, handler.toJS);
}

late final Future<void> future;

@override
late final DenoServer runtime;

@override
Future<void> close({bool force = false}) async {
await runtime.shutdown().toDart;
}

@override
Future<void> ready() => future;

@override
String? get hostname => runtime.addr.hostname;

@override
int? get port => runtime.addr.port;

@override
String? remoteAddress(web.Request request) {
final addr = request.remoteAddr.hostname.contains(':')
? '[${request.remoteAddr.hostname}]'
: request.remoteAddr.hostname;
return '$addr:${request.remoteAddr.port}';
}
}
185 changes: 185 additions & 0 deletions lib/src/server/runtime/js/node_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import 'dart:async';
import 'dart:js_interop';

import 'package:web/web.dart' as web;

import '../../../http/headers.dart';
import '../../../http/request.dart';
import '../../server.dart';
import '_utils.dart';

extension type IncomingHttpHeaders._(JSObject _) implements JSObject {
external String? get host;
}

@JS('Object')
extension type JSObjectStatic._(JSAny _) {
external static JSArray<JSArray> entries(JSObject _);
}

@JS('Array')
extension type JSArrayStatic._(JSAny _) {
external static bool isArray(JSAny? _);
}

extension type Socket._(JSObject _) implements JSObject {
external String? get remoteAddress;
external String? get remoteFamily;
external int? get remotePort;
external String? get localAddress;
external int? get localPort;
external String? get localFamily;
}

extension type IncomingMessage._(web.ReadableStream _)
implements web.ReadableStream {
external IncomingHttpHeaders get headers;
external String? url;
external String? method;
external Socket get socket;

Request toSpryRequest() {
final hostname = headers.host ?? socket.localAddress ?? '';

final spryHeaders = Headers();
for (final part in JSObjectStatic.entries(headers).toDart) {
final [name, values] = part.toDart;
if (JSArrayStatic.isArray(values)) {
for (final value in (values as JSArray<JSString>).toDart) {
spryHeaders.add((name as JSString).toDart, value.toDart);
}
continue;
}

final value = values.dartify();
if (value is String) {
spryHeaders.add((name as JSString).toDart, value);
}
}

return Request(
method: method ?? 'get',
url: Uri.parse('http://$hostname${url ?? '/'}'),
headers: spryHeaders,
body: toDartStream(),
runtime: this,
);
}
}

extension type ServerResponse._(web.WritableStream _)
implements web.WritableStream {
external void writeHead(int statis, JSArray<JSArray<JSString>> headers);
external void write(JSAny chunk);
external void end();
}

extension type ListenOptions._(JSObject _) implements JSObject {
external factory ListenOptions({
String? host,
int? port,
bool? exclusive,
});
}

extension type NodeServer._(JSObject _) implements JSObject {
external NodeServer listen(ListenOptions options, JSFunction fn);
external void close();
external void closeAllConnections();
external JSAny? address();
}

extension type NodeHttp._(JSObject _) implements JSObject {
external NodeServer createServer(JSFunction requestListener);
}

extension type AddressInfo._(JSObject _) implements JSObject {
external String address;
external int port;
}

class RuntimeServer extends Server<NodeServer, IncomingMessage> {
RuntimeServer(super.options) {
final completer = Completer<void>();
future = completer.future;
void ready() => completer.complete();

final nodeServerOptions = ListenOptions(
host: options.hostname,
port: options.port,
exclusive: switch (options.reusePort) { true => false, _ => true },
);

unawaited(Future.microtask(() async {
final http = (await importModule('node:http'.toJS).toDart) as NodeHttp;
runtime = http.createServer(listen.toJS);
runtime.listen(nodeServerOptions, ready.toJS);
}));
}

late final Future<void> future;

@override
late final NodeServer runtime;

@override
Future<void> close({bool force = false}) async {
if (force) {
runtime.closeAllConnections();
}
runtime.close();
}

@override
Future<void> ready() => future;

void listen(IncomingMessage request, ServerResponse response) {
unawaited(fetch(request.toSpryRequest()).then(
(spryResponse) async {
final headers = <JSArray<JSString>>[];
for (final (name, value) in spryResponse.headers) {
headers.add([name.toJS, value.toJS].toJS);
}
response.writeHead(spryResponse.status, headers.toJS);
await for (final chunk in spryResponse) {
response.write(chunk.toJS);
}
response.end();
},
));
}

@override
String? get hostname {
final addr = runtime.address();
if (addr.typeofEquals('string')) {
return (addr as JSString).toDart;
} else if (addr.typeofEquals('object')) {
return (addr as AddressInfo).address;
}

return null;
}

@override
int? get port {
final addr = runtime.address();
if (addr.typeofEquals('object')) {
return (addr as AddressInfo).port;
}

return null;
}

@override
String? remoteAddress(IncomingMessage request) {
final addr = request.socket.remoteAddress;
final port = request.socket.remotePort;
if (addr != null && port != null) {
final hostname = request.socket.remoteFamily == 'IPv6' ? '[$addr]' : addr;
return '$hostname:$port';
}

return null;
}
}
70 changes: 70 additions & 0 deletions lib/src/server/runtime/js_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'dart:js_interop';
import 'dart:js_util' as js_utils show globalThis;

import '../../http/request.dart';
import '../../http/response.dart';
import '../server.dart';
import 'js/bun_server.dart' as bun;
import 'js/node_server.dart' as node;
import 'js/deno_server.dart' as deno;

extension type Versions._(JSObject _) implements JSObject {
external String get node;
}

extension type NodeProcess._(JSObject _) implements JSObject {
external Versions get versions;
}

extension type GlobalThis._(JSObject _) implements JSObject {
// ignore: non_constant_identifier_names
external JSObject? get Bun;
// ignore: non_constant_identifier_names
external JSObject? get Deno;
external set self(GlobalThis _);
}

final GlobalThis globalThis = js_utils.globalThis as GlobalThis;

class RuntimeServer extends Server {
RuntimeServer._(super.options, this.server);

factory RuntimeServer(ServerOptions options) {
late final Server server;
if (globalThis.Bun.isTruthy.toDart) {
server = bun.RuntimeServer(options);
} else if (globalThis.Deno.isTruthy.toDart) {
server = deno.RuntimeServer(options);
} else {
// Node does not define self, but Dart compiled to JS always operates on self.
// This fixes the issue where Node cannot read self.
globalThis.self = globalThis;
server = node.RuntimeServer(options);
}

return RuntimeServer._(options, server);
}

final Server server;

@override
Future<void> ready() => server.ready();

@override
Future<void> close({bool force = false}) => server.close(force: force);

@override
Future<Response> fetch(Request request) => server.fetch(request);

@override
get runtime => server.runtime;

@override
String? get hostname => server.hostname;

@override
int? get port => server.port;

@override
String? remoteAddress(request) => server.remoteAddress(request);
}
3 changes: 3 additions & 0 deletions lib/src/server/runtime/runtime.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export 'stub_server.dart'
if (dart.library.js_interop) 'js_server.dart'
if (dart.library.io) 'dart_server.dart';
36 changes: 36 additions & 0 deletions lib/src/server/runtime/stub_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import '../../http/request.dart';
import '../../http/response.dart';
import '../server.dart';

final class RuntimeServer extends Server {
RuntimeServer(super.options);

@override
String? get hostname => throw UnimplementedError();

@override
int? get port => throw UnimplementedError();

@override
get runtime => throw UnimplementedError();

@override
Future<void> close({bool force = false}) {
throw UnimplementedError();
}

@override
Future<void> ready() {
throw UnimplementedError();
}

@override
Future<Response> fetch(Request request) {
throw UnimplementedError();
}

@override
String? remoteAddress(request) {
throw UnimplementedError();
}
}
27 changes: 27 additions & 0 deletions lib/src/server/serve.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'runtime/runtime.dart';
import 'server.dart';

/// Creates a Corss-Server.
///
/// Example:
/// ```dart
/// final server = serve(
/// fetch: (request, _) => Response.fromString('Hello'),
/// );
/// await server.ready();
/// ```
Server serve({
String? hostname,
int? port,
bool? reusePort,
required ServerHandler fetch,
}) {
final options = ServerOptions(
hostname: hostname,
port: port,
reusePort: reusePort ?? true,
fetch: fetch,
);

return RuntimeServer(options);
}
80 changes: 80 additions & 0 deletions lib/src/server/server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:async';

import '../http/request.dart';
import '../http/response.dart';

/// Spry server handler.
typedef ServerHandler = FutureOr<Response> Function(
Request request, Server server);

/// Spry server options.
class ServerOptions {
const ServerOptions({
this.hostname,
this.port,
required this.fetch,
required this.reusePort,
});

/// The server listen hostname.
final String? hostname;

/// The server listen port.
final int? port;

/// Do you allow shared ports.
final bool reusePort;

/// The processing program of the server after receiving the request.
final ServerHandler fetch;
}

/// The spry server.
abstract class Server<S, R> {
/// Creates a new server.
const Server(this.options);

/// Returns the server options.
final ServerOptions options;

/// Returns the server runtime-platform server instance.
S get runtime;

/// Returns resolved listen hostname.
String? get hostname;

/// Returns resolved listen port.
int? get port;

/// Returns listen url.
String? get url {
if (hostname == null) {
return null;
}

final addr = switch (hostname?.contains(':')) {
true => '[$hostname]',
false => hostname,
_ => options.hostname,
};
if (port == null) {
return addr;
}

return 'http://$addr:$port';
}

/// The server fetch.
Future<Response> fetch(Request request) async {
return await options.fetch(request, this);
}

/// Wait for the server to be ready.
Future<void> ready();

/// Close the server.
Future<void> close({bool force = false});

/// Resolve a request remote address.
String? remoteAddress(R request);
}
144 changes: 144 additions & 0 deletions lib/src/spry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import 'dart:async';

import 'package:routingkit/routingkit.dart';

import '_utils.dart';
import 'http/request.dart';
import 'http/response.dart';
import 'event.dart';
import 'locals.dart';
import 'server/server.dart';
import 'server/serve.dart' as spry;

/// Middleware next callback.
typedef Next = Future<Response> Function();

/// Spry middleware.
typedef Middleware = FutureOr<Response> Function(Event event, Next next);

/// Spry request handler.
typedef Handler<T> = FutureOr<T>? Function(Event event);

/// Spry application.
class Spry {
Spry._({
this.dev = false,
Locals? locals,
}) : locals = locals ?? Locals({}),
middleware = createRouter(),
router = createRouter();

/// The app is dev mode.
final bool dev;

/// The app global locals.
final Locals locals;

/// Middleware router context.
final RouterContext<Middleware> middleware;

/// Request handler route context.
final RouterContext<Handler> router;

/// Returns the app bind server.
Server? get server => _server;
Server? _server;

/// Fetch a request handler.
Future<Response> fetch(Request request) async {
final route = switch (request.method) {
'HEAD' => switch (findRoute(router, 'HEAD', request.url.path)) {
MatchedRoute<Handler> route => route,
_ => findRoute(router, 'GET', request.url.path),
},
String method => switch (findRoute(router, method, request.url.path)) {
MatchedRoute<Handler> route => route,
_ => findRoute(router, null, request.url.path),
},
};
if (route == null) {
return Response(null, status: 204);
}

final event = Event(
id: createUniqueID(),
app: this,
request: request,
address: server?.remoteAddress(request.runtime),
);
Next respond = () async {
if (route.params?.isNotEmpty == true) {
event.params.clear();
event.params.addAll(route.params!);
}
return await responder(event, route.data(event));
};
for (final match
in findAllRoutes(middleware, request.method, request.url.path)) {
final prev = respond;
respond = () async {
if (match.params?.isNotEmpty == true) {
event.params.clear();
event.params.addAll(match.params!);
}
return await match.data(event, prev);
};
}

return await respond();
}

/// Creates a server from the app.
Server serve({String? hostname, int? port, bool? reusePort}) {
if (_server != null) {
throw StateError('Server is already running.');
}

return _server = spry.serve(
hostname: hostname,
port: port,
reusePort: reusePort,
fetch: (request, _) => fetch(request),
);
}

/// Register a middleware.
void use(Middleware fn, {String? method, String path = '/'}) {
addRoute(middleware, method, path, fn);
}

/// Listen a request.
void on<T>(Handler<T> handler, {String? method, String path = '/'}) {
addRoute(router, method, path, handler);
}

/// Listen all request on the handler.
void all<T>(String path, Handler<T> handler) => on(path: path, handler);

/// Listen a get request.
void get<T>(String path, Handler<T> handler) =>
on(method: 'GET', path: path, handler);

/// Listen a head request.
void head<T>(String path, Handler<T> handler) =>
on(method: 'HEAD', path: path, handler);

// Listen a post request.
void post<T>(String path, Handler<T> handler) =>
on(method: 'POST', path: path, handler);

/// Listen a patch request.
void patch<T>(String path, Handler<T> handler) =>
on(method: 'PATCH', path: path, handler);

/// Listen a put request.
void put<T>(String path, Handler<T> handler) =>
on(method: 'PUT', path: path, handler);

/// listen a delete request.
void delete<T>(String path, Handler<T> handler) =>
on(method: 'DELETE', path: path, handler);
}

/// Creates a new [Spry] application.
const createSpry = Spry._;
52 changes: 0 additions & 52 deletions lib/src/types.dart

This file was deleted.

13 changes: 0 additions & 13 deletions lib/src/utils/_create_chain_handler.dart

This file was deleted.

33 changes: 0 additions & 33 deletions lib/src/utils/_create_response_with.dart

This file was deleted.

13 changes: 0 additions & 13 deletions lib/src/utils/create_error.dart

This file was deleted.

24 changes: 0 additions & 24 deletions lib/src/utils/create_event.dart

This file was deleted.

34 changes: 0 additions & 34 deletions lib/src/utils/create_spry.dart

This file was deleted.

34 changes: 0 additions & 34 deletions lib/src/utils/hijack.dart

This file was deleted.

37 changes: 0 additions & 37 deletions lib/src/utils/request_utils.dart

This file was deleted.

50 changes: 0 additions & 50 deletions lib/src/utils/to_handler.dart

This file was deleted.

24 changes: 0 additions & 24 deletions lib/src2/context.dart

This file was deleted.

1 change: 0 additions & 1 deletion lib/src2/response.dart

This file was deleted.

Loading