diff --git a/CHANGELOG.md b/CHANGELOG.md index 7398dc4..9ae1e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 35e220a..3ab1ab7 100644 --- a/README.md +++ b/README.md @@ -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!'); } ``` diff --git a/analysis_options.yaml b/analysis_options.yaml index dee8927..1b61ab5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 diff --git a/bun.lockb b/bun.lockb index 68c68fa..bf30689 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/demo.dart b/demo.dart deleted file mode 100644 index 40d2eba..0000000 --- a/demo.dart +++ /dev/null @@ -1,3 +0,0 @@ -main(args, {dmo}) { - print(dmo); -} diff --git a/example/README.md b/example/README.md index 84fcd84..1b3d6f1 100644 --- a/example/README.md +++ b/example/README.md @@ -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 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 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}'); +} + +``` diff --git a/example/app.dart b/example/app.dart index 24fd11f..6de07bd 100644 --- a/example/app.dart +++ b/example/app.dart @@ -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 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}'); +} diff --git a/example/bun.dart b/example/bun.dart deleted file mode 100644 index 8ea274e..0000000 --- a/example/bun.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:spry/bun.dart'; - -import 'app.dart'; - -void main() async { - final serve = toBunServe(app)..port = 3000; - Bun.serve(serve); - - print('🎉 Server listen on http://127.0.0.1:3000'); -} diff --git a/example/io.dart b/example/io.dart deleted file mode 100644 index 50e57e1..0000000 --- a/example/io.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:io'; - -import 'package:spry/io.dart'; - -import 'app.dart'; - -void main() async { - final handler = toIOHandler(app); - final server = await HttpServer.bind('127.0.0.1', 3000); - - server.listen(handler); - - print('🎉 Server listen on http://127.0.0.1:3000'); -} diff --git a/example/server.dart b/example/server.dart new file mode 100644 index 0000000..6af9eba --- /dev/null +++ b/example/server.dart @@ -0,0 +1,13 @@ +import 'package:spry/server.dart'; + +Future 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}'); +} diff --git a/lib/bun.dart b/lib/bun.dart deleted file mode 100644 index 0322ec2..0000000 --- a/lib/bun.dart +++ /dev/null @@ -1,224 +0,0 @@ -import 'dart:async'; -import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; - -import 'package:web/web.dart' as web; - -import 'spry.dart'; -import 'ws.dart'; -import 'web.dart'; - -/// The Bun upgrade to WebSocket options. -extension type UpgradeOptions._(JSObject _) implements JSObject { - /// Upgrade sent headers. - external web.Headers? headers; - - /// Contextual [data] can be attached to a new WebSocket. - external JSAny? data; -} - -/// Bun socket address. -extension type SocketAddress._(JSObject _) implements JSObject { - /// address, E.g: `127.0.0.1` - external String get address; - - /// Connection port. - external JSNumber get port; - - /// Address type, E.g: `ipv4` - external String get family; -} - -/// The Bun server -extension type Server._(JSObject _) implements JSObject { - /// Upgrade a request to websocket. - external bool upgrade(web.Request request, [UpgradeOptions? options]); - - /// Gets the request client socket address. - external SocketAddress? requestIP(web.Request request); -} - -/// Bun serve. -extension type Serve._(JSObject _) implements JSObject { - /// Creates a new Bun serve. - factory Serve({ - required Future Function(web.Request, Server) fetch, - WebSocketHandler? websocket, - }) { - JSPromise handle(web.Request request, Server server) { - return fetch(request, server).toJS; - } - - final inner = JSObject() - ..['fetch'] = handle.toJS - ..['websocket'] = websocket; - - return Serve._(inner); - } - - /// Gets/Set a serve port. - external int port; - - /// Gets/Set a serve hostname, Default is `0.0.0.0`. - external String hostname; - - /// Gets/Set a serve websocket handler. - external WebSocketHandler? websocket; - - /// The serve on request call fn. - external JSPromise fetch(web.Request request, Server server); -} - -/// Bun serevr websocket. -extension type ServerWebSocket._(JSObject _) implements JSObject { - /// The upgrade contextual data. - external JSAny? get data; - - /// The websocket ready state. - external JSNumber get readyState; - - /// Send a message to client. - external JSNumber send(JSAny message, [bool? boolean]); - - /// Close websocket. - external void close([JSNumber? code, JSString? reason]); -} - -/// Bun websocket handler. -extension type WebSocketHandler._(JSObject _) implements JSObject { - /// Creates a new websocket handler. - factory WebSocketHandler({ - required void Function(ServerWebSocket, JSAny) message, - void Function(ServerWebSocket)? open, - void Function(ServerWebSocket, [JSNumber?, JSString?])? close, - void Function(ServerWebSocket, JSAny)? error, - }) { - final inner = JSObject()..['message'] = message.toJS; - - return WebSocketHandler._(inner); - } -} - -/// Global Bun module. -@JS('Bun') -extension type Bun._(JSAny _) implements JSAny { - /// Creates a bun server for [serve]. - external static Server serve(Serve serve); -} - -/// Create a Bun serve object using Spry application. -Serve toBunServe(Spry app) { - return Serve( - fetch: _createBunServeFetch(app), - websocket: _createWebSocketHandler(), - ); -} - -WebSocketHandler _createWebSocketHandler() { - (Event, Hooks) resolve(JSAny? data) { - return switch (data.dartify()) { - (Event, Hooks) context => context, - _ => throw createError('WebSocket context error'), - }; - } - - final handler = WebSocketHandler( - message: (ws, raw) { - final message = switch (raw) { - JSString value => Message.text(value.toDart), - JSArrayBuffer buffer => Message.bytes(buffer.toDart.asUint8List()), - JSUint8Array bytes => Message.bytes(bytes.toDart), - _ => throw createError('WebSocket message is illegal'), - }; - final (event, hooks) = resolve(ws.data); - final peer = _BunPeer(event, ws); - - hooks.message(peer, message); - }, - ); - - return handler; -} - -Future Function(web.Request, Server) _createBunServeFetch( - Spry app) { - final handler = toHandler(app); - - return (request, server) async { - final event = createWebEvent(app, request); - final connectionInfo = server.requestIP(request); - if (connectionInfo != null) { - setClientAddress( - event, '${connectionInfo.address}:${connectionInfo.port}'); - } - - bool upgraded = false; - onUpgrade(event, (hooks) async { - final headers = await hooks.upgrade(event); - final options = UpgradeOptions._(JSObject()) - ..headers = switch (headers) { - Headers headers => toWebHeaders(headers), - _ => null, - } - ..data = (event, hooks).jsify(); - - return upgraded = server.upgrade(request, options); - }); - - final response = await handler(event); - return switch (upgraded) { - true => null, - _ => toWebResponse(response), - }; - }; -} - -class _BunPeer implements Peer { - const _BunPeer(this.event, this.websocket); - - final Event event; - final ServerWebSocket websocket; - - @override - String get extensions => - event.request.headers.get('Sec-Websocket-Extensions') ?? ''; - - @override - String? get protocol => null; - - @override - ReadyState get readyState => ReadyState(websocket.readyState.toDartInt); - - @override - void remove(Object? key) => event.remove(key); - - @override - T? get(Object? key) => event.get(key); - - @override - void set(Object? key, T value) => event.set(key, value); - - @override - void send(Message message, [bool? compress = false]) { - final raw = switch (message.raw) { - String value => value.toJS, - _ => message.bytes().toJS, - }; - - websocket.send(raw, compress); - } - - @override - Future close([int? code, String? reason]) async { - websocket.close(code?.toJS, reason?.toJS); - } - - @override - Spry get app => event.app; - - @override - get raw => event.raw; - - @override - Request get request => event.request; -} diff --git a/lib/cookie.dart b/lib/cookie.dart deleted file mode 100644 index 95cc67a..0000000 --- a/lib/cookie.dart +++ /dev/null @@ -1,408 +0,0 @@ -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; - -import 'spry.dart'; - -/// [Set-Cookie#samesitesamesite-value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) -enum SameSite { lax, strict, none } - -/// Spry cookies. -abstract interface class Cookies { - /// Gets a Request/Response cookie value. - String? get(String name); - - /// Gets all Request/Response cookies. - Iterable<(String, String)> getAll(); - - /// Sets a new cookie. - void set( - String name, - String value, { - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool? partitioned, - }); - - /// Deletes a cookie. - void delete( - String name, { - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool? partitioned, - }); - - /// Serialize a cookie. - String serialize( - String name, - String value, { - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool? partitioned, - bool signed, - }); -} - -String _toHttpDateString(DateTime date) { - const List wkday = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - const List month = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec" - ]; - - DateTime d = date.toUtc(); - StringBuffer sb = StringBuffer() - ..write(wkday[d.weekday - 1]) - ..write(", ") - ..write(d.day <= 9 ? "0" : "") - ..write(d.day.toString()) - ..write(" ") - ..write(month[d.month - 1]) - ..write(" ") - ..write(d.year.toString()) - ..write(d.hour <= 9 ? " 0" : " ") - ..write(d.hour.toString()) - ..write(d.minute <= 9 ? ":0" : ":") - ..write(d.minute.toString()) - ..write(d.second <= 9 ? ":0" : ":") - ..write(d.second.toString()) - ..write(" GMT"); - - return sb.toString(); -} - -class _SetCookie { - _SetCookie( - this.name, - this.value, { - this.expires, - this.maxAge, - this.domain, - this.path, - this.secure, - this.httpOnly, - this.sameSite, - this.partitioned, - }); - - final String name; - final String value; - DateTime? expires; - int? maxAge; - String? domain; - String? path; - bool? secure; - bool? httpOnly; - SameSite? sameSite; - bool? partitioned; - - @override - String toString() { - final buffer = StringBuffer() - ..write(name) - ..write('=') - ..write(value); - - if (expires != null) { - buffer - ..write('; Expires=') - ..write(_toHttpDateString(expires!)); - } - - if (maxAge != null) { - buffer - ..write('; Max-Age=') - ..write(maxAge); - } - - if (domain != null) { - buffer - ..write('; Domain=') - ..write(domain); - } - - if (path != null) { - buffer - ..write('; Path=') - ..write(path); - } - - if (secure == true) buffer.write('; Secure'); - if (httpOnly == true) buffer.write('; HttpOnly'); - if (partitioned == true) buffer.write('; Partitioned'); - if (sameSite != null) { - buffer.write('; SameSite='); - buffer.write(switch (sameSite!) { - SameSite.lax => 'Lax', - SameSite.strict => 'Strict', - SameSite.none => 'None', - }); - } - - return buffer.toString(); - } -} - -final class _CookiesImpl extends Iterable<_SetCookie> implements Cookies { - _CookiesImpl(this.cookies, this.hmac); - - final Map cookies; - final Hmac? hmac; - final setCookies = <_SetCookie>[]; - - @override - Iterator<_SetCookie> get iterator => setCookies.iterator; - - void clear() => setCookies.clear(); - - @override - String? get(String name) { - final normalizedName = name.toLowerCase(); - for (final cookie in cookies.entries) { - if (cookie.key.toLowerCase() == normalizedName) { - return decodeSignedValue(cookie.value); - } - } - - for (final cookie in this) { - if (cookie.name.toLowerCase() == normalizedName) { - return decodeSignedValue(cookie.value); - } - } - - return null; - } - - @override - Iterable<(String, String)> getAll() sync* { - for (final cookie in cookies.entries) { - final value = decodeSignedValue(cookie.value); - if (value == null) continue; - - yield (cookie.key, value); - } - - for (final cookie in this) { - final value = decodeSignedValue(cookie.value); - if (value == null) continue; - - yield (cookie.name, value); - } - } - - @override - void set( - String name, - String value, { - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool? partitioned, - }) { - setCookies.add(_SetCookie( - name, - encodeSignedValue(value), - expires: expires, - maxAge: maxAge, - domain: domain, - path: path, - secure: secure, - httpOnly: httpOnly, - sameSite: sameSite, - partitioned: partitioned, - )); - } - - @override - void delete( - String name, { - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool? partitioned, - }) { - setCookies.removeWhere((e) => e.name.toLowerCase() == name.toLowerCase()); - - set(name, '', - expires: expires ?? DateTime.now(), - maxAge: maxAge, - domain: domain, - path: path, - secure: secure, - httpOnly: httpOnly, - sameSite: sameSite, - partitioned: partitioned); - } - - @override - String serialize( - String name, - String value, { - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool signed = true, - bool? partitioned, - }) { - return _SetCookie( - name, - signed ? encodeSignedValue(value) : value, - expires: expires, - maxAge: maxAge, - domain: domain, - path: path, - secure: secure, - httpOnly: httpOnly, - sameSite: sameSite, - partitioned: partitioned, - ).toString(); - } - - String encodeSignedValue(String value) { - if (value.isEmpty) return ''; - if (hmac == null) return Uri.encodeComponent(value); - - final bytes = utf8.encode(value); - final hex = base64Url.encode(hmac!.convert(bytes).bytes); - final encoded = base64Url.encode(bytes); - - return Uri.encodeComponent('$encoded.$hex'.replaceAll('=', '')); - } - - String? decodeSignedValue(String signed) { - if (signed.isEmpty) return null; - if (hmac == null) return Uri.decodeComponent(signed); - - final parts = signed.split('.'); - if (parts.length < 2) return null; - - final [...encodedParts, sign] = parts; - final bytes = base64Url.decode(Uri.decodeComponent(encodedParts.join('.'))); - final hex = - base64Url.encode(hmac!.convert(bytes).bytes).replaceAll('=', ''); - - if (hex == Uri.decodeComponent(sign)) { - return utf8.decode(bytes); - } - - return null; - } -} - -_CookiesImpl _createCookies(Event event, Hmac? hmac) { - final cookies = useHeaders(event) - .getAll('cookie') - .map((e) => e.split(';')) - .expand((e) => e) - .map((e) { - final [name, ...values] = e.split('='); - return MapEntry(name.toLowerCase().trim(), values.join('=').trim()); - }); - - return _CookiesImpl(Map.fromEntries(cookies), hmac); -} - -const _kCookies = #spry.event.cookies; - -/// Enable cookie suppory. -/// -/// The [enableCookie] create a Spry handler. -/// -/// ```dart -/// app.use(enableCookie()); -/// ``` -Handler enableCookie({ - String? secret, - Hash algorithm = sha256, - bool autoSecureSet = true, - DateTime? expires, - int? maxAge, - String? domain, - String? path, - bool? secure, - bool? httpOnly, - SameSite? sameSite, - bool? partitioned, -}) { - final hmac = switch (secret) { - String secret => Hmac(algorithm, utf8.encode(secret)), - _ => null - }; - - return (event) async { - final cookies = event.locals[_kCookies] = _createCookies(event, hmac); - - final response = await next(event); - final hasSchema = useRequestURI(event).isScheme; - final autoSecure = switch (autoSecureSet) { - true => hasSchema('https') || hasSchema('wss'), - _ => false, - }; - - for (final cookie in cookies) { - cookie - ..expires = cookie.expires ?? expires - ..maxAge = cookie.maxAge ?? maxAge - ..domain = cookie.domain ?? domain - ..path = cookie.path ?? path - ..secure = cookie.secure ?? secure ?? autoSecure - ..httpOnly = cookie.httpOnly ?? httpOnly - ..sameSite = cookie.sameSite ?? sameSite - ..partitioned = cookie.partitioned ?? partitioned; - - response.headers.add('set-cookie', cookie.toString()); - } - - cookies.clear(); - - return response; - }; -} - -/// Returns a [Cookies] instance for the [event]. -Cookies useCookies(Event event) { - return switch (event.locals[_kCookies]) { - Cookies cookies => cookies, - _ => throw createError( - 'Cookies are not enabled.' - 'Please enable using `app.use(enableCookie(...))`', - ), - }; -} diff --git a/lib/io.dart b/lib/io.dart deleted file mode 100644 index b543943..0000000 --- a/lib/io.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'spry.dart'; -import 'ws.dart'; - -const _kUpgraded = #spry.io.websocket.upgraded; - -/// Create a new Dart IO handler for the Spry application. -Future Function(HttpRequest request) toIOHandler(Spry app) { - final handler = toHandler(app); - - return (httpRequest) async { - final spryRequest = Request( - method: httpRequest.method.toUpperCase(), - uri: httpRequest.requestedUri, - headers: _createSpryHeaders(httpRequest.headers), - body: httpRequest, - ); - final event = createEvent(app: app, request: spryRequest, raw: httpRequest); - - if (httpRequest.connectionInfo != null) { - setClientAddress(event, - '${httpRequest.connectionInfo?.remoteAddress.address}:${httpRequest.connectionInfo?.remotePort}'); - } - - final httpResponse = httpRequest.response; - final spryResponse = await handler(event); - - httpResponse.statusCode = spryResponse.status; - httpResponse.reasonPhrase = spryResponse.statusText; - _writeSpryHeaders(httpResponse, spryResponse.headers); - - if (spryResponse.body != null) { - await httpResponse.addStream(spryResponse.body!); - } - - await httpResponse.close(); - }; -} - -void _registerUpgrade(HttpRequest request, Event event) { - onUpgrade(event, (hooks) async { - if (!WebSocketTransformer.isUpgradeRequest(request)) { - return false; - } - - _writeSpryHeaders(request.response, await hooks.upgrade(event)); - final websocket = await WebSocketTransformer.upgrade(request); - final peer = _IOPeer(event, websocket); - - await hooks.open(peer); - websocket.listen( - (raw) async { - final message = switch (raw) { - String text => Message.text(text), - Uint8List bytes => Message.bytes(bytes), - List bytes => Message.bytes(Uint8List.fromList(bytes)), - _ => throw createError('WebSocket message is illegal'), - }; - - await hooks.message(peer, message); - }, - onDone: () async { - await hooks.close(peer, websocket.closeCode, websocket.closeReason); - await request.response.close(); - }, - onError: (error) async => hooks.error(peer, error), - ); - - event.set(_kUpgraded, true); - - return true; - }); -} - -Headers _createSpryHeaders(HttpHeaders httpHeaders) { - final inner = Headers(); - httpHeaders.forEach((name, values) { - for (final value in values) { - inner.add(name, value); - } - }); - - return inner; -} - -void _writeSpryHeaders(HttpResponse response, Headers? headers) { - if (headers == null || headers.isEmpty) { - return; - } - - for (final (name, value) in headers) { - response.headers.add(name, value, preserveHeaderCase: true); - } -} - -class _IOPeer implements Peer { - const _IOPeer(this.event, this.websocket); - - final Event event; - final WebSocket websocket; - - @override - String get extensions => websocket.extensions; - - @override - String? get protocol => websocket.protocol; - - @override - ReadyState get readyState => ReadyState(websocket.readyState); - - @override - void send(Message message, [bool? compress]) => websocket.add(message.raw); - - @override - Future close([int? code, String? reason]) async { - await websocket.close(code, reason); - } - - @override - T? get(Object? key) => event.get(key); - - @override - void set(Object? key, T value) => event.set(key, value); - - @override - void remove(Object? key) => event.remove(key); - - @override - Spry get app => event.app; - - @override - get raw => event.raw; - - @override - Request get request => event.request; -} diff --git a/lib/plain.dart b/lib/plain.dart deleted file mode 100644 index 5c1262c..0000000 --- a/lib/plain.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'spry.dart'; - -/// Creates a plain handler. -Future Function(Request) toPlainHandler(Spry app) { - final handler = toHandler(app); - - return (request) async => - await handler(createEvent(app: app, request: request, raw: request)); -} diff --git a/lib/server.dart b/lib/server.dart new file mode 100644 index 0000000..285e037 --- /dev/null +++ b/lib/server.dart @@ -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'; diff --git a/lib/spry.dart b/lib/spry.dart index 1328db2..b59d70f 100644 --- a/lib/spry.dart +++ b/lib/spry.dart @@ -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'; diff --git a/lib/src/_constants.dart b/lib/src/_constants.dart deleted file mode 100644 index 2f62c4a..0000000 --- a/lib/src/_constants.dart +++ /dev/null @@ -1,3 +0,0 @@ -const kNext = #spry.event.next; -const kParams = #spry.event.params; -const kClientAddress = #spry.event.client_address; diff --git a/lib/src/_io_utils.dart b/lib/src/_io_utils.dart new file mode 100644 index 0000000..1fb7c9b --- /dev/null +++ b/lib/src/_io_utils.dart @@ -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; + } +} diff --git a/lib/src/_utils.dart b/lib/src/_utils.dart new file mode 100644 index 0000000..2420982 --- /dev/null +++ b/lib/src/_utils.dart @@ -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 responder(Event event, Object? data) async { + return switch (data) { + Future future => responder(event, await future), + Response response => response, + Responder(:final respond) => respond(event), + Stream stream => Response(stream), + Stream> stream => Response(stream.map(Uint8List.fromList)), + Stream> 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; + } +} diff --git a/lib/src/event.dart b/lib/src/event.dart new file mode 100644 index 0000000..6848635 --- /dev/null +++ b/lib/src/event.dart @@ -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? 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 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; +} diff --git a/lib/src/http/_utils.dart b/lib/src/http/_utils.dart new file mode 100644 index 0000000..f5bc5ef --- /dev/null +++ b/lib/src/http/_utils.dart @@ -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 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; + } +} diff --git a/lib/src/http/cross_file.dart b/lib/src/http/cross_file.dart new file mode 100644 index 0000000..4591e1d --- /dev/null +++ b/lib/src/http/cross_file.dart @@ -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; diff --git a/lib/src/http/formdata.dart b/lib/src/http/formdata.dart new file mode 100644 index 0000000..e000d86 --- /dev/null +++ b/lib/src/http/formdata.dart @@ -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 lastModified() => _file.lastModified(); + + @override + Future length() => _file.length(); + + @override + String? get mimeType => _file.mimeType; + + @override + Stream openRead([int? start, int? end]) => + _file.openRead(start, end); + + @override + String get path => _file.path; + + @override + Future readAsBytes() => _file.readAsBytes(); + + @override + Future readAsString({Encoding encoding = utf8}) => + _file.readAsString(encoding: encoding); + + @override + Future saveTo(String path) => _file.saveTo(path); +} + +/// Form-data type. +/// +/// The type is form-data fields container, impl of [List]. +extension type FormData._(List entries) + implements List { + /// 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? 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 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 parse({ + required String boundary, + required Stream 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 = []; + 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 _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 _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; +} diff --git a/lib/src/http/headers.dart b/lib/src/http/headers.dart index c4e4950..3ae1506 100644 --- a/lib/src/http/headers.dart +++ b/lib/src/http/headers.dart @@ -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? 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 getAll(String name) { - return where(_createHeaderEqTest(name)).map((e) => e.$2); + /// Gets a header all values. + Iterable 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); + } } diff --git a/lib/src/http/http_message.dart b/lib/src/http/http_message.dart index bc84df8..155c543 100644 --- a/lib/src/http/http_message.dart +++ b/lib/src/http/http_message.dart @@ -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 { + /// Creates a new [Request]/[Response]. + /// + /// See: + /// - [Request] + /// - [Response] + HttpMessage({Headers? headers, Stream? 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? get body; + /// Returns the [Request]/[Response] body stream. + final Stream body; + + @override + StreamSubscription 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 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 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 readAsJson() async { + return jsonDecode(await readAsString()); + } + + /// Read the body as to [FormData]. + Future 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 readAsUrlencoded() async { + return URLSearchParams.parse(await readAsString()); + } } diff --git a/lib/src/http/http_status_reason_phrase.dart b/lib/src/http/http_status_reason_phrase.dart deleted file mode 100644 index 357b1fa..0000000 --- a/lib/src/http/http_status_reason_phrase.dart +++ /dev/null @@ -1,91 +0,0 @@ -/// HTTP status code reason phrases. -extension HttpStatusReasonPhrase on int { - /// Returns the reason phrase for the HTTP status code. - String get httpStatusReasonPhrase { - return switch (this) { - // 1xx Informational - 100 => 'Continue', - 101 => 'Switching Protocols', - 102 => 'Processing', - - // 2xx Success - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 207 => 'Multi-Status', - 208 => 'Already Reported', - 226 => 'IM Used', - - // 3xx Redirection - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 306 => 'unused', - 307 => 'Temporary Redirect', - 308 => 'Permanent Redirect', - - // 4xx Client Error - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Timeout', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Payload Too Large', - 414 => 'URI Too Long', - 415 => 'Unsupported Media Type', - 416 => 'Range Not Satisfiable', - 417 => 'Expectation Failed', - 418 => "I'm a teapot", - 421 => 'Misdirected Request', - 422 => 'Unprocessable Entity', - 423 => 'Locked', - 424 => 'Failed Dependency', - 425 => 'Too Early', - 426 => 'Upgrade Required', - 428 => 'Precondition Required', - 429 => 'Too Many Requests', - 431 => 'Request Header Fields Too Large', - 451 => 'Unavailable For Legal Reasons', - - // 5xx Server Error - 500 => 'Internal Server Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Timeout', - 505 => 'HTTP Version Not Supported', - 506 => 'Variant Also Negotiates', - 507 => 'Insufficient Storage', - 508 => 'Loop Detected', - 510 => 'Not Extended', - 511 => 'Network Authentication Required', - - // Unofficial codes - 103 => 'Checkpoint', - 420 => 'Enhance Your Calm', - 450 => 'Blocked by Windows Parental Controls', - 498 => 'Invalid Token', - 499 => 'Token Required', - 509 => 'Bandwidth Limit Exceeded', - 530 => 'Site is frozen', - 598 => 'Network read timeout error', - 599 => 'Network connect timeout error', - _ => 'Unknown', - }; - } -} diff --git a/lib/src/http/request.dart b/lib/src/http/request.dart index b767abb..f3f960e 100644 --- a/lib/src/http/request.dart +++ b/lib/src/http/request.dart @@ -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 extends HttpMessage { + /// Creates a new [Request] instance. + Request({ required String method, - required Uri uri, - Encoding? encoding, - Stream? 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 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? 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; } diff --git a/lib/src/http/response.dart b/lib/src/http/response.dart index 236ceaf..83957d1 100644 --- a/lib/src/http/response.dart +++ b/lib/src/http/response.dart @@ -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? 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 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.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? 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; + } } diff --git a/lib/src/http/url_search_params.dart b/lib/src/http/url_search_params.dart new file mode 100644 index 0000000..ef06b20 --- /dev/null +++ b/lib/src/http/url_search_params.dart @@ -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? 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 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 = >{}; + for (final (name, value) in this) { + final values = params[name] ?? []; + values.add(tryRun(Uri.encodeQueryComponent, value)); + } + + return Uri(queryParameters: params).query; + } +} diff --git a/lib/src/locals.dart b/lib/src/locals.dart new file mode 100644 index 0000000..dba4af3 --- /dev/null +++ b/lib/src/locals.dart @@ -0,0 +1,5 @@ +/// Spry locals. +extension type Locals._(Map _) implements Map { + /// Creates a new locals. + factory Locals([Map? init]) => Locals._(init ?? {}); +} diff --git a/lib/src/next.dart b/lib/src/next.dart deleted file mode 100644 index f1ffd2f..0000000 --- a/lib/src/next.dart +++ /dev/null @@ -1,16 +0,0 @@ -import '_constants.dart'; -import 'http/response.dart'; -import 'types.dart'; -import 'utils/_create_response_with.dart'; - -Future next(Event event) async { - final handler = event.locals[kNext]; - - // Remove current handler of event. - event.locals.remove(kNext); - - return switch (handler) { - Handler handler => createResponseWith(event, handler(event)), - _ => Response(null, status: 204), - }; -} diff --git a/lib/src/responder/_resolve.dart b/lib/src/responder/_resolve.dart new file mode 100644 index 0000000..882360d --- /dev/null +++ b/lib/src/responder/_resolve.dart @@ -0,0 +1,3 @@ +export '_resolve_stub.dart' + if (dart.library.js_interop) '_resolve_web.dart' + if (dart.library.io) '_resolve_io.dart'; diff --git a/lib/src/responder/_resolve_io.dart b/lib/src/responder/_resolve_io.dart new file mode 100644 index 0000000..f9d7f0d --- /dev/null +++ b/lib/src/responder/_resolve_io.dart @@ -0,0 +1,18 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import '../_io_utils.dart'; +import '../event.dart'; +import '../http/response.dart'; + +Future resove(Event _, Object? data) async { + if (data is HttpClientResponse) { + return Response( + data.map(Uint8List.fromList), + status: data.statusCode, + headers: data.headers.toSpryHeaders(), + ); + } + + return null; +} diff --git a/lib/src/responder/_resolve_stub.dart b/lib/src/responder/_resolve_stub.dart new file mode 100644 index 0000000..e2dd5ff --- /dev/null +++ b/lib/src/responder/_resolve_stub.dart @@ -0,0 +1,4 @@ +import '../event.dart'; +import '../http/response.dart'; + +Future resove(Event event, Object? _) async => null; diff --git a/lib/src/responder/_resolve_web.dart b/lib/src/responder/_resolve_web.dart new file mode 100644 index 0000000..63b958a --- /dev/null +++ b/lib/src/responder/_resolve_web.dart @@ -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 resove(Event _, Object? data) async { + final response = data as web.Response; + if (response.isA()) { + return Response( + response.body?.toDartStream(), + status: response.status, + headers: response.headers.toSpryHeaders(), + ); + } + + return null; +} diff --git a/lib/src/responder/responder.dart b/lib/src/responder/responder.dart new file mode 100644 index 0000000..51849d3 --- /dev/null +++ b/lib/src/responder/responder.dart @@ -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 respond(Event event); +} diff --git a/lib/src/routes+all.dart b/lib/src/routes+all.dart deleted file mode 100644 index 1898b46..0000000 --- a/lib/src/routes+all.dart +++ /dev/null @@ -1,9 +0,0 @@ -// ignore_for_file: file_names - -import 'types.dart'; - -/// The [all] method extension. -extension RoutesAll on Routes { - /// Adds a all request method route. - void all(String path, Handler handler) => on(null, path, handler); -} diff --git a/lib/src/routes+group.dart b/lib/src/routes+group.dart deleted file mode 100644 index f3eb638..0000000 --- a/lib/src/routes+group.dart +++ /dev/null @@ -1,53 +0,0 @@ -// ignore_for_file: file_names - -import 'types.dart'; -import 'utils/_create_chain_handler.dart'; - -extension RoutesGroup on Routes { - Routes grouped({String? path, Iterable? uses}) { - Routes routes = this; - - if (path != null && path.isNotEmpty) { - routes = _PrefixRoutes(routes, path); - } - - if (uses != null && uses.isNotEmpty) { - routes = _StackRoutes(routes, uses.toList(growable: false).reversed); - } - - return routes; - } - - Routes group(void Function(Routes routes) fn, - {String? path, Iterable? uses}) { - final routes = grouped(path: path, uses: uses); - - fn(routes); - - return routes; - } -} - -class _PrefixRoutes implements Routes { - const _PrefixRoutes(this.root, this.prefix); - - final Routes root; - final String prefix; - - @override - void on(String? method, String path, Handler handler) { - root.on(method, '$prefix/$path', handler); - } -} - -class _StackRoutes implements Routes { - const _StackRoutes(this.root, this.uses); - - final Routes root; - final Iterable uses; - - @override - void on(String? method, String path, Handler handler) { - root.on(method, path, createChainHandler(uses, handler)); - } -} diff --git a/lib/src/routes+methods.dart b/lib/src/routes+methods.dart deleted file mode 100644 index 205748e..0000000 --- a/lib/src/routes+methods.dart +++ /dev/null @@ -1,34 +0,0 @@ -// ignore_for_file: file_names - -import 'types.dart'; - -/// The `` extension. -extension RoutesMethods on Routes { - /// Registers a `GET` route that responds to with the result of the [handler]. - /// - /// ```dart - /// app.get('/say-hello', (event) async => 'Hello, world!'); - /// ``` - void get(String path, Handler handler) => on('GET', path, handler); - - /// Registers a `POST` route that responds to with the result of the [handler]. - /// - /// ```dart - /// app.post('/say/:name', (event) async => request.params.get('name')); - /// ``` - void post(String path, Handler handler) => on('POST', path, handler); - - /// Registers a `PUT` route that responds to with the result of the [handler]. - void put(String path, Handler handler) => on('PUT', path, handler); - - /// Registers a `PATCH` route that responds to with the result of the [handler]. - void patch(String path, Handler handler) => - on('PATCH', path, handler); - - /// Registers a `DELETE` route that responds to with the result of the [handler]. - void delete(String path, Handler handler) => - on('DELETE', path, handler); - - /// Registers a `HEAD` route that responds to with the result of the [handler]. - void head(String path, Handler handler) => on('HEAD', path, handler); -} diff --git a/lib/src/server/runtime/dart_server.dart b/lib/src/server/runtime/dart_server.dart new file mode 100644 index 0000000..38078a9 --- /dev/null +++ b/lib/src/server/runtime/dart_server.dart @@ -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 { + RuntimeServer(super.options) { + final completer = Completer(); + 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 future; + + @override + late final HttpServer runtime; + + @override + Future ready() => future; + + @override + Future 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, + ); +} diff --git a/lib/src/server/runtime/js/_utils.dart b/lib/src/server/runtime/js/_utils.dart new file mode 100644 index 0000000..00a6e58 --- /dev/null +++ b/lib/src/server/runtime/js/_utils.dart @@ -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 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 { + web.ReadableStream toWebReadableStream() { + late final StreamSubscription 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(), + ), + ); + } +} diff --git a/lib/src/server/runtime/js/bun_server.dart b/lib/src/server/runtime/js/bun_server.dart new file mode 100644 index 0000000..bdfdfcd --- /dev/null +++ b/lib/src/server/runtime/js/bun_server.dart @@ -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 { + RuntimeServer(super.options) { + JSPromise 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 future; + + @override + late final BunServer runtime; + + @override + Future ready() async {} + + @override + Future 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}'; + } +} diff --git a/lib/src/server/runtime/js/deno_server.dart b/lib/src/server/runtime/js/deno_server.dart new file mode 100644 index 0000000..d4d4f7f --- /dev/null +++ b/lib/src/server/runtime/js/deno_server.dart @@ -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 { + RuntimeServer(super.options) { + final completer = Completer(); + + void ready() => completer.complete(); + JSPromise 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 future; + + @override + late final DenoServer runtime; + + @override + Future close({bool force = false}) async { + await runtime.shutdown().toDart; + } + + @override + Future 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}'; + } +} diff --git a/lib/src/server/runtime/js/node_server.dart b/lib/src/server/runtime/js/node_server.dart new file mode 100644 index 0000000..f47f44d --- /dev/null +++ b/lib/src/server/runtime/js/node_server.dart @@ -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 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).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> 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 { + RuntimeServer(super.options) { + final completer = Completer(); + 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 future; + + @override + late final NodeServer runtime; + + @override + Future close({bool force = false}) async { + if (force) { + runtime.closeAllConnections(); + } + runtime.close(); + } + + @override + Future ready() => future; + + void listen(IncomingMessage request, ServerResponse response) { + unawaited(fetch(request.toSpryRequest()).then( + (spryResponse) async { + final headers = >[]; + 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; + } +} diff --git a/lib/src/server/runtime/js_server.dart b/lib/src/server/runtime/js_server.dart new file mode 100644 index 0000000..ea7d8c6 --- /dev/null +++ b/lib/src/server/runtime/js_server.dart @@ -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 ready() => server.ready(); + + @override + Future close({bool force = false}) => server.close(force: force); + + @override + Future 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); +} diff --git a/lib/src/server/runtime/runtime.dart b/lib/src/server/runtime/runtime.dart new file mode 100644 index 0000000..4f98b11 --- /dev/null +++ b/lib/src/server/runtime/runtime.dart @@ -0,0 +1,3 @@ +export 'stub_server.dart' + if (dart.library.js_interop) 'js_server.dart' + if (dart.library.io) 'dart_server.dart'; diff --git a/lib/src/server/runtime/stub_server.dart b/lib/src/server/runtime/stub_server.dart new file mode 100644 index 0000000..42c553c --- /dev/null +++ b/lib/src/server/runtime/stub_server.dart @@ -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 close({bool force = false}) { + throw UnimplementedError(); + } + + @override + Future ready() { + throw UnimplementedError(); + } + + @override + Future fetch(Request request) { + throw UnimplementedError(); + } + + @override + String? remoteAddress(request) { + throw UnimplementedError(); + } +} diff --git a/lib/src/server/serve.dart b/lib/src/server/serve.dart new file mode 100644 index 0000000..fe10567 --- /dev/null +++ b/lib/src/server/serve.dart @@ -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); +} diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart new file mode 100644 index 0000000..56e03bc --- /dev/null +++ b/lib/src/server/server.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import '../http/request.dart'; +import '../http/response.dart'; + +/// Spry server handler. +typedef ServerHandler = FutureOr 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 { + /// 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 fetch(Request request) async { + return await options.fetch(request, this); + } + + /// Wait for the server to be ready. + Future ready(); + + /// Close the server. + Future close({bool force = false}); + + /// Resolve a request remote address. + String? remoteAddress(R request); +} diff --git a/lib/src/spry.dart b/lib/src/spry.dart new file mode 100644 index 0000000..3d50db1 --- /dev/null +++ b/lib/src/spry.dart @@ -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 Function(); + +/// Spry middleware. +typedef Middleware = FutureOr Function(Event event, Next next); + +/// Spry request handler. +typedef Handler = FutureOr? 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; + + /// Request handler route context. + final RouterContext router; + + /// Returns the app bind server. + Server? get server => _server; + Server? _server; + + /// Fetch a request handler. + Future fetch(Request request) async { + final route = switch (request.method) { + 'HEAD' => switch (findRoute(router, 'HEAD', request.url.path)) { + MatchedRoute route => route, + _ => findRoute(router, 'GET', request.url.path), + }, + String method => switch (findRoute(router, method, request.url.path)) { + MatchedRoute 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(Handler handler, {String? method, String path = '/'}) { + addRoute(router, method, path, handler); + } + + /// Listen all request on the handler. + void all(String path, Handler handler) => on(path: path, handler); + + /// Listen a get request. + void get(String path, Handler handler) => + on(method: 'GET', path: path, handler); + + /// Listen a head request. + void head(String path, Handler handler) => + on(method: 'HEAD', path: path, handler); + + // Listen a post request. + void post(String path, Handler handler) => + on(method: 'POST', path: path, handler); + + /// Listen a patch request. + void patch(String path, Handler handler) => + on(method: 'PATCH', path: path, handler); + + /// Listen a put request. + void put(String path, Handler handler) => + on(method: 'PUT', path: path, handler); + + /// listen a delete request. + void delete(String path, Handler handler) => + on(method: 'DELETE', path: path, handler); +} + +/// Creates a new [Spry] application. +const createSpry = Spry._; diff --git a/lib/src/types.dart b/lib/src/types.dart deleted file mode 100644 index acadab5..0000000 --- a/lib/src/types.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:meta/meta.dart'; -import 'package:routingkit/routingkit.dart'; - -import 'http/request.dart'; - -/// Request event interface. -/// -/// This [Event] is also the context of the request. -abstract interface class Event { - /// Contains custom data that was added to the request within [Handler]. - Map get locals; - - /// Gets current event request. - Request get request; - - /// Returns event raw. - dynamic get raw; - - /// Returns Spry application - Spry get app; -} - -/// Spry handler. -typedef Handler = FutureOr Function(Event event); - -/// Spry Routes -abstract interface class Routes { - /// Adds a handler on match [method] and [path]. - void on(String? method, String path, Handler handler); -} - -/// Spry application. -abstract interface class Spry implements Routes { - /// The [RouterContext] bound to the current [Spry] application - @internal - RouterContext get router; - - /// Stack handler in Spry application. - @internal - List get stack; - - /// Adds a [Handler] into [stack]. - void use(Handler handler); -} - -/// Spry error. -abstract interface class SpryError implements Error { - /// The error message. - String get message; -} diff --git a/lib/src/utils/_create_chain_handler.dart b/lib/src/utils/_create_chain_handler.dart deleted file mode 100644 index bee71c8..0000000 --- a/lib/src/utils/_create_chain_handler.dart +++ /dev/null @@ -1,13 +0,0 @@ -import '../_constants.dart'; -import '../types.dart'; - -Handler createChainHandler(Iterable uses, Handler handler) { - if (uses.isEmpty) return handler; - return uses.fold( - handler, - (next, current) => (event) { - event.locals[kNext] = next; - return current(event); - }, - ); -} diff --git a/lib/src/utils/_create_response_with.dart b/lib/src/utils/_create_response_with.dart deleted file mode 100644 index 334d191..0000000 --- a/lib/src/utils/_create_response_with.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import '../http/response.dart'; -import '../types.dart'; - -Future createResponseWith(Event event, FutureOr value) async { - final response = switch (await value) { - Response response => response, - Stream stream => Response(stream), - Stream> stream => Response(stream.map(Uint8List.fromList)), - Map json => Response.json(json), - List json => Response.json(json), - Iterable json => Response.json(json.toList()), - String text => Response.text(text), - Object value => _fallbackResponseOf(value), - _ => Response(null, status: 204), - }; - - if (response.headers.get('X-Powered-By') == null) { - response.headers.set('X-Powered-By', 'spry.fun'); - } - - return response; -} - -Response _fallbackResponseOf(dynamic value) { - try { - return Response.json(value.toJson()); - } catch (_) { - return Response.text(value.toString()); - } -} diff --git a/lib/src/utils/create_error.dart b/lib/src/utils/create_error.dart deleted file mode 100644 index b1262bf..0000000 --- a/lib/src/utils/create_error.dart +++ /dev/null @@ -1,13 +0,0 @@ -import '../types.dart'; - -/// Creates a new [SpryError]. -SpryError createError(String message) { - return _SpryErrorImpl(message); -} - -class _SpryErrorImpl extends Error implements SpryError { - _SpryErrorImpl(this.message); - - @override - final String message; -} diff --git a/lib/src/utils/create_event.dart b/lib/src/utils/create_event.dart deleted file mode 100644 index 5d3d440..0000000 --- a/lib/src/utils/create_event.dart +++ /dev/null @@ -1,24 +0,0 @@ -import '../http/request.dart'; -import '../types.dart'; - -/// Creates a new Spry [Event] instance. -Event createEvent( - {required Spry app, required Request request, required Raw raw}) { - return _EventImpl(app: app, raw: raw, request: request); -} - -class _EventImpl implements Event { - _EventImpl({required this.app, required this.raw, required this.request}); - - @override - late final locals = {}; - - @override - final Raw raw; - - @override - final Request request; - - @override - final Spry app; -} diff --git a/lib/src/utils/create_spry.dart b/lib/src/utils/create_spry.dart deleted file mode 100644 index c33b406..0000000 --- a/lib/src/utils/create_spry.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:routingkit/routingkit.dart'; - -import '../types.dart'; - -/// Creates a new [Spry] application. -Spry createSpry({RouterContext? router, Iterable? stack}) { - return _SpryImpl( - router: switch (router) { - RouterContext router => router, - _ => createRouter(), - }, - stack: [...?stack], - ); -} - -class _SpryImpl implements Spry { - const _SpryImpl({required this.router, required this.stack}); - - @override - final RouterContext router; - - @override - final List stack; - - @override - void on(String? method, String path, Handler handler) { - addRoute(router, method, path, handler); - } - - @override - void use(Handler handler) { - stack.add(handler); - } -} diff --git a/lib/src/utils/hijack.dart b/lib/src/utils/hijack.dart deleted file mode 100644 index da4dcf8..0000000 --- a/lib/src/utils/hijack.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import '../types.dart'; -import 'create_error.dart'; - -const _hijackerMark = #spry.request.hijacker; - -typedef Hijacker = FutureOr Function( - Stream stream, - Sink sink, -); - -typedef FailHijack = FutureOr Function(); - -/// Whether the request has been hijacked. -bool canHijacked(Event event) => event.locals[_hijackerMark] is Hijacker; - -void hijack(Event event, Hijacker hijacker) async { - if (canHijacked(event)) { - throw createError("This request has already been hijacked."); - } - - event.locals[_hijackerMark] = hijacker; -} - -void onHijack() {} - -Hijacker useHijecker(Event event) { - return switch (event.locals[_hijackerMark]) { - Hijacker hijacker => hijacker, - _ => throw createError("This request not hijacked."), - }; -} diff --git a/lib/src/utils/request_utils.dart b/lib/src/utils/request_utils.dart deleted file mode 100644 index 087d3af..0000000 --- a/lib/src/utils/request_utils.dart +++ /dev/null @@ -1,37 +0,0 @@ -import '../_constants.dart'; -import '../http/headers.dart'; -import '../types.dart'; - -/// Reads current request event [Headers]. -Headers useHeaders(Event event) => event.request.headers; - -/// Gets current request event client address. -/// -/// If result is not `null`, the value formated of `:`. -String? getClientAddress(Event event) { - return switch (event.locals[kClientAddress]) { - String value => value, - _ => null, - }; -} - -/// Sets a client address in request [Event]. -/// -/// **NOTE**: This [setClientAddress] is provided to adapter developers -void setClientAddress(Event event, String address) { - event.locals[kClientAddress] = address; -} - -/// Returns the [Uri] for current request [Event]. -Uri useRequestURI(Event event) => event.request.uri; - -/// Returns the request [Event] matched route params. -Map useParams(Event event) { - return switch (event.locals[kParams]) { - Map params => params, - _ => {}, - }; -} - -/// Returns selected request matched route param. -String? useParam(Event event, String key) => useParams(event)[key]; diff --git a/lib/src/utils/to_handler.dart b/lib/src/utils/to_handler.dart deleted file mode 100644 index 680e21a..0000000 --- a/lib/src/utils/to_handler.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:routingkit/routingkit.dart' as routingkit; - -import '../_constants.dart'; -import '../http/response.dart'; -import '../types.dart'; -import '_create_chain_handler.dart'; -import '_create_response_with.dart'; - -/// Creates a new Spry [Handler] using a [Spry] application. -Handler toHandler(Spry app) { - final handler = createChainHandler( - app.stack.reversed, - _createRouterHandler(app.router), - ); - - return (event) => createResponseWith(event, handler(event)); -} - -Handler _createRouterHandler(routingkit.RouterContext router) { - return (event) { - final request = event.request; - final route = _lookup(router, request.method, request.uri.path); - - if (route == null) { - return Response(null, status: 404); - } - - event.locals[kParams] = route.params; - - return route.data(event); - }; -} - -routingkit.MatchedRoute? _lookup( - routingkit.RouterContext router, String method, String path) { - routingkit.MatchedRoute? findRoute(String? method) { - return routingkit.findRoute(router, method ?? '', path); - } - - return switch (method) { - 'HEAD' => switch (findRoute('HEAD')) { - routingkit.MatchedRoute route => route, - _ => _lookup(router, 'GET', path), - }, - String method => switch (findRoute(method)) { - routingkit.MatchedRoute route => route, - _ => findRoute(null), - }, - }; -} diff --git a/lib/src2/context.dart b/lib/src2/context.dart deleted file mode 100644 index 968b858..0000000 --- a/lib/src2/context.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// Spry request event context. -extension type SpryContext._(Map _) - implements Map { - /// Creates a new Spry context. - factory SpryContext([Map? init]) { - return SpryContext._(init ?? {}); - } - - /// Returns matched route params. - Map? get params => _[#spry.event.context.params]; - - /// Sets matched route params - set params(Map? params) { - _[#spry.event.context.params] = params; - } - - /// Returns trusted IP Address of client. - String? get clientAddress => _[#spry.event.context.client_address]; - - /// Sets trusted IP address of client. - set clientAddress(String? address) { - _[#spry.event.context.client_address] = address; - } -} diff --git a/lib/src2/response.dart b/lib/src2/response.dart deleted file mode 100644 index 1ed2e21..0000000 --- a/lib/src2/response.dart +++ /dev/null @@ -1 +0,0 @@ -abstract interface class SpryResponse {} diff --git a/lib/web.dart b/lib/web.dart deleted file mode 100644 index bb8089f..0000000 --- a/lib/web.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:async'; -import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; -import 'dart:typed_data'; - -import 'package:web/web.dart' as web; - -import 'spry.dart'; - -/// Create a new [Event] for web. -Event createWebEvent(Spry app, web.Request request) { - final spryRequest = Request( - method: request.method.toUpperCase(), - uri: Uri.parse(request.url), - headers: toSpryHeaders(request.headers), - body: _getWebRequestBody(request), - ); - - return createEvent(app: app, request: spryRequest, raw: request); -} - -/// Spry response to web response object. -web.Response toWebResponse(Response response) { - final init = web.ResponseInit( - status: response.status, - statusText: response.statusText, - headers: toWebHeaders(response.headers), - ); - - if (response.body == null) { - return web.Response(null, init); - } - - return web.Response(_toWebReadableStream(response.body!), init); -} - -/// Spry headers to web headers object. -web.Headers toWebHeaders(Headers headers) { - final webHeaders = web.Headers(); - - for (final (name, value) in headers) { - webHeaders.append(name, value); - } - - return webHeaders; -} - -/// Creates a new Spry [Headers] for web headers object. -Headers toSpryHeaders(web.Headers headers) { - final spryHeaders = Headers(); - - headers.forEach((value, name, _) { - spryHeaders.add(name, value); - }); - - for (final value in headers.getSetCookie().toDart) { - spryHeaders.add('set-cookie', value.toDart); - } - - return spryHeaders; -} - -/// Creates a new web handler for the Spry application. -Future Function(web.Request) toWebHandler(Spry app) { - final handler = toHandler(app); - - return (webRequest) async { - final event = createWebEvent(app, webRequest); - final response = await handler(event); - - return toWebResponse(response); - }; -} - -extension on web.Headers { - @JS('forEach') - external void _forEach(JSFunction fn); - - void forEach( - void Function(String value, String name, web.Headers headers) fn, - ) { - _forEach(fn.toJS); - } -} - -@JS('ReadableByteStreamController') -extension type _ReadableByteStreamController._(JSObject _) implements JSObject { - external int? get desiredSize; - external void enqueue(JSUint8Array chunk); - external void error([JSAny e]); - external void close(); -} - -Stream? _getWebRequestBody(web.Request request) { - if (request.body == null) return null; - - return Stream.fromFuture( - request.arrayBuffer().toDart.then((value) => value.toDart.asUint8List()), - ); -} - -web.ReadableStream _toWebReadableStream(Stream stream) { - late final StreamSubscription subscription; - void start(_ReadableByteStreamController controller) { - subscription = stream.listen((event) { - controller.enqueue(event.toJS); - - if (controller.desiredSize == null || - controller.desiredSize == -1 || - controller.desiredSize == 0) { - subscription.pause(); - } - }); - subscription.onDone(() => controller.close()); - subscription.onError((error) { - final e = switch (error) { - String(toJS: final toJS) => toJS, - Exception exception => Error.safeToString(exception).toJS, - Error error => Error.safeToString(error).toJS, - JSAny any => any, - _ => error.jsify(), - }; - - controller.error(e); - }); - } - - final underlyingSource = JSObject() - ..['type'] = 'bytes'.toJS - ..['start'] = start.toJS - ..['pull'] = ((JSAny _) => subscription.resume()).toJS - ..['cancel'] = (() { - unawaited(subscription.cancel()); - }).toJS; - - return web.ReadableStream(underlyingSource); -} diff --git a/lib/ws.dart b/lib/ws.dart deleted file mode 100644 index 46830e5..0000000 --- a/lib/ws.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'spry.dart'; - -/// WebSocket's ready state. -extension type const ReadyState(int code) implements int { - /// Unknown ready state. - static const unknown = ReadyState(-1); - - /// Connecting ready state. - static const connecting = ReadyState(0); - - /// Open ready state. - static const open = ReadyState(1); - - /// Closing ready state. - static const closing = ReadyState(2); - - /// Closed ready state. - static const closed = ReadyState(3); -} - -/// WebSocket message, -class Message { - const Message._(this.raw); - - /// Creates a [String] message. - factory Message.text(String text) => Message._(text); - - /// Creates a [Uint8List] message. - factory Message.bytes(Uint8List bytes) => Message._(bytes); - - /// Message raw data, Types: Uint8List or String - final dynamic raw; - - /// Returns the message text. - String text() { - return switch (raw) { - String value => value, - _ => utf8.decode(raw), - }; - } - - /// Returns the message bytes. - Uint8List bytes() { - return switch (raw) { - Uint8List bytes => bytes, - _ => utf8.encode(raw), - }; - } -} - -/// Websocket peer. -abstract interface class Peer implements Event { - /// The peer ready state. - ReadyState get readyState; - - /// The peer protocol. - /// - /// TODO all adapter support, current only `dart:io` - String? get protocol; - - /// Returns the peer extensions. - String get extensions; - - /// Send a message. - void send(Message message, [bool? compress]); - - /// Cloese the websocket. - Future close([int? code, String? reason]); -} - -/// Spry websocket hooks. -abstract interface class Hooks { - /// Hooks for websocket opened. - FutureOr open(Peer peer); - - /// Hooks for websocet close. - FutureOr close(Peer peer, [int? code, String? reason]); - - /// Hooks for websocket error. - FutureOr error(Peer peer, Object? error); - - /// Hooks for websocket message. - FutureOr message(Peer peer, Message message); -} - -/// Websocket Upgrade handle. -typedef UpgradeHandle = FutureOr Function(Hooks); - -/// Upgrade request event to websocket. -Future upgrade(Event event, Hooks hooks) async { - final handle = event.locals[#spry.ws.on_upgrade]; - if (handle is UpgradeHandle) { - return await handle(hooks); - } - - return false; -} - -/// The [ws] extension. -extension RoutesWS on Routes { - /// Register a websocket handler with [hooks]. - void ws(String path, Hooks hooks, [Handler? fallback]) { - return get(path, (event) async { - if (await upgrade(event, hooks)) { - return Response(null, status: 101); - } - - return fallback?.call(event) ?? Response(null, status: 426); - }); - } -} - -/// Define a [Hooks]. -Hooks defineHooks({ - required FutureOr Function(Peer peer, Message message) message, - FutureOr Function(Peer peer)? open, - FutureOr Function(Peer peer, [int? code, String? reason])? close, - FutureOr Function(Peer peer, Object? error)? error, - FutureOr Function(Event event)? upgrade, -}) { - return _DefinedHooks( - onMessage: message, - onOpen: open, - onClose: close, - onError: error, - onUpgrade: upgrade, - ); -} - -class _DefinedHooks implements Hooks { - const _DefinedHooks({ - required this.onMessage, - this.onClose, - this.onOpen, - this.onError, - this.onUpgrade, - }); - - final FutureOr Function(Peer, Message) onMessage; - final FutureOr Function(Peer)? onOpen; - final FutureOr Function(Peer, [int?, String?])? onClose; - final FutureOr Function(Peer, Object?)? onError; - final FutureOr Function(Event)? onUpgrade; - - @override - FutureOr message(Peer peer, Message message) { - return onMessage(peer, message); - } - - @override - FutureOr close(Peer peer, [int? code, String? reason]) { - return onClose?.call(peer, code, reason); - } - - @override - FutureOr error(Peer peer, Object? error) { - return onError?.call(peer, error); - } - - @override - FutureOr open(Peer peer) { - return onOpen?.call(peer); - } -} diff --git a/package.json b/package.json index 491d521..c614572 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "vitepress": "^1.3.1" }, "devDependencies": { - "@types/bun": "^1.1.9" + "@types/bun": "^1.1.9", + "@types/deno": "^2.0.0" } } diff --git a/pubspec.yaml b/pubspec.yaml index 34c528b..7c16ab8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,18 +1,21 @@ name: spry description: Spry is a lightweight, composable Dart web framework designed to work collaboratively with various runtime platforms. -version: 5.2.0 +version: 6.0.0-dev.1 homepage: https://spry.fun repository: https://github.com/medz/spry environment: - sdk: ^3.4.3 + sdk: ^3.5.0 dependencies: - crypto: ^3.0.3 - meta: ">=1.12.0 <2.0.0" + cross_file: ^0.3.4+2 + http_parser: ^4.1.1 + mime: ^2.0.0 + path: ^1.9.1 routingkit: ^4.1.0 - web: ^1.0.0 + slugid: ^1.1.2 + web: ^1.1.0 dev_dependencies: - lints: ^4.0.0 + lints: ^5.0.0 test: ^1.24.0 diff --git a/src/http/_base_http_message.dart b/src/http/_base_http_message.dart deleted file mode 100644 index f6a4db3..0000000 --- a/src/http/_base_http_message.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:typed_data'; - -/// Base HTTP message -abstract interface class BaseHttpMessage { - /// The message body stream. - Stream? get body; - - /// Observe whether the body used. - bool get bodyUsed; - - /// Returns the message body as bytes. - Future bytes(); - - /// Returns the message body as string. - Future text(); - - /// Returns the message body as decoded JSON object. - Future json(); -} diff --git a/src/http/headers.dart b/src/http/headers.dart deleted file mode 100644 index 965faff..0000000 --- a/src/http/headers.dart +++ /dev/null @@ -1,122 +0,0 @@ -const _singleValueNames = [ - /// General - 'cache-control', - 'connection', - 'date', - 'expect', - 'pragma', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'via', - 'warning', - - /// Response - 'accept-ranges', - 'age', - 'content-length', - 'content-md5', - 'content-type', - 'etag', - 'last-modified', - 'location', - 'server', - 'vary', - 'www-authenticate' -]; - -/// Spry headers. -extension type const Headers._(List<(String, String)> _) - implements Iterable<(String, String)> { - /// Creates a new Spry headers instance. - factory Headers([Map? init]) { - final inner = <(String, String)>[]; - if (init != null && init.isNotEmpty) { - for (final MapEntry(:key, :value) in init.entries) { - inner.add((key, value)); - } - } - - return Headers._(inner); - } - - /// Delete a header values for [name]. - void delete(String name) => _.removeWhere(_createIgnoreCaseTester(name)); - - /// Appends a new header KV part into headers. - void append(String name, String value) { - final lowerCase = name.toLowerCase(); - - // If the header name is single value defined, remove prev values. - if (_singleValueNames.contains(lowerCase)) { - delete(name); - } - - _.add((name, value)); - } - - /// Returns a header value of [name]. - /// - /// If the header value is multi-values, Using the `, ` joined - /// and returns it. - /// - /// If not set the header, return `null` value. - /// - /// **Note**: The method not support returns `set-cookoe` name, if - /// you need get `set-cookie` values, please using [getSetCookie] method. - String get(String name) { - return where(_createIgnoreCaseTesterWithoutSetCookie(name)) - .map((e) => e.$2) - .join(', '); - } - - /// Returns the `set-cookie` name header values. - Iterable getSetCookie() { - return where(_createIgnoreCaseTester('set-cookie')).map((e) => e.$2); - } - - /// Observe whether the header [name] exists. - bool has(String name) { - return any(_createIgnoreCaseTester(name)); - } - - /// Sets a header value for [name]. - void set(String name, String value) { - delete(name); - append(name, value); - } - - /// Returns the headers all name keys. - Iterable keys() { - return map((e) => _normalizeHeaderName(e.$1)).toSet(); - } - - /// Returns the headers all values. - Iterable values() sync* { - for (final name in keys()) { - if (name.toLowerCase() == 'set-cookie') { - continue; - } - - yield get(name); - } - - yield* getSetCookie(); - } -} - -String _normalizeHeaderName(String name) => name.toLowerCase(); - -bool Function((String, String)) _createIgnoreCaseTester(String name) { - final normalized = _normalizeHeaderName(name); - return (e) => _normalizeHeaderName(e.$1) == normalized; -} - -bool Function((String, String)) _createIgnoreCaseTesterWithoutSetCookie( - String name) { - final test = _createIgnoreCaseTester(name); - return (e) => switch (e.$1.toLowerCase()) { - 'set-cookie' => false, - _ => test(e), - }; -} diff --git a/src/http/request.dart b/src/http/request.dart deleted file mode 100644 index 7ac32d2..0000000 --- a/src/http/request.dart +++ /dev/null @@ -1,2 +0,0 @@ -/// Spry request. -abstract interface class Request {} diff --git a/src/locals.dart b/src/locals.dart deleted file mode 100644 index 0a953de..0000000 --- a/src/locals.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// Spry app locals. -extension type Locals._(Map _) implements Map { - /// Returns a nullable value of [key], value typed for [T]. - T? maybeOf(Object? key) { - return switch (_[key]) { - T value => value, - _ => null, - }; - } - - /// Returns a [T] typed value of [key] - T of(Object key) { - return switch (maybeOf(key)) { - T value => value, - _ => throw Error(), // TODO - }; - } -} diff --git a/src/params.dart b/src/params.dart deleted file mode 100644 index 977e280..0000000 --- a/src/params.dart +++ /dev/null @@ -1 +0,0 @@ -extension type Params._(Map _) implements Map {} diff --git a/src/platform.dart b/src/platform.dart deleted file mode 100644 index b7269af..0000000 --- a/src/platform.dart +++ /dev/null @@ -1 +0,0 @@ -abstract interface class Platform {} diff --git a/src/request_event.dart b/src/request_event.dart deleted file mode 100644 index 9c3efcc..0000000 --- a/src/request_event.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'locals.dart'; -import 'params.dart'; -import 'platform.dart'; -import 'http/request.dart'; -import 'route.dart'; - -abstract interface class RequestEvent { - /// Additional data made available through the adapter. - Platform? get platform; - - /// Contains custom data that was added to the request within the handle hook. - Locals get locals; - - /// The [params] of the current route - e.g. for a route like `/blog/:slug`, - /// a `{ slug: string }` map. - Params get params; - - /// Info about the current route. - Route get route; - - /// The requested URL. - Uri get url; - - /// The original request object - Request get request; - - /// If you need to set headers for the response, you can do so using the this method. - /// This is useful if you want the page to be cached, for example: - /// - /// ```dart - /// function GET(RequestEvent event) { - /// event.setHeaders({ - /// "age": "100", - /// }); - /// - /// return text('hello'); - /// } - /// ``` - /// - /// You cannot add a `set-cookie` header with `setHeaders` — use the `cookies` API instead. - void setHeaders(Map headers); - - /// The client's IP address, set by the adapter. - String getClientAddress(); -} diff --git a/src/route.dart b/src/route.dart deleted file mode 100644 index 616c18f..0000000 --- a/src/route.dart +++ /dev/null @@ -1,4 +0,0 @@ -abstract interface class Route { - /// The ID of the current route. - String get id; -} diff --git a/src/types.dart b/src/types.dart deleted file mode 100644 index 7ed1f4e..0000000 --- a/src/types.dart +++ /dev/null @@ -1 +0,0 @@ -abstract interface class Spry {}