Added and migrated html_builder

This commit is contained in:
thomashii@dukefirehawk.com 2021-05-01 10:48:36 +08:00
parent b091b435e4
commit 2a69ecf91b
34 changed files with 3379 additions and 16 deletions

View file

@ -24,7 +24,9 @@
* Migrated angel_jael to 4.0.0 (1/1 test passed) * Migrated angel_jael to 4.0.0 (1/1 test passed)
* Migrated pub_sub to 4.0.0 (16/16 tests passed) * Migrated pub_sub to 4.0.0 (16/16 tests passed)
* Migrated production to 3.0.0 (0/0 tests passed) * Migrated production to 3.0.0 (0/0 tests passed)
* Added html_builder and migrated to 2.0.0 (16/16 tests passed)
* Updated hot to 3.0.0 (in progress) * Updated hot to 3.0.0 (in progress)
* Added range_header and migrated to 2.0.0 (16/16 tests passed)
* Updated static to 3.0.0 (in progress) * Updated static to 3.0.0 (in progress)
* Update basic-sdk-2.12.x boilerplate (in progress) * Update basic-sdk-2.12.x boilerplate (in progress)

View file

@ -14,8 +14,8 @@ import 'package:html_builder/elements.dart';
import 'package:html_builder/html_builder.dart'; import 'package:html_builder/html_builder.dart';
import 'package:io/ansi.dart'; import 'package:io/ansi.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:vm_service_lib/vm_service_lib.dart' as vm; import 'package:vm_service/vm_service.dart' as vm;
import 'package:vm_service_lib/vm_service_lib_io.dart' as vm; import 'package:vm_service/vm_service_io.dart' as vm;
import 'package:watcher/watcher.dart'; import 'package:watcher/watcher.dart';
/// A utility class that watches the filesystem for changes, and starts new instances of an Angel server. /// A utility class that watches the filesystem for changes, and starts new instances of an Angel server.
@ -374,7 +374,7 @@ class HotReloader {
if (hot) { if (hot) {
var report = await _client.reloadSources(_mainIsolate.id); var report = await _client.reloadSources(_mainIsolate.id);
if (!report.success) { if (report.success != null) {
_logWarning( _logWarning(
'Hot reload failed - perhaps some sources have not been generated yet.'); 'Hot reload failed - perhaps some sources have not been generated yet.');
} }

View file

@ -1,6 +1,6 @@
name: angel_hot name: angel_hot
description: Supports hot reloading/hot code push of Angel servers on file changes. description: Supports hot reloading/hot code push of Angel servers on file changes.
version: 3.0.0 version: 4.0.0
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/hot homepage: https://github.com/angel-dart/hot
publish_to: none publish_to: none
@ -10,19 +10,19 @@ dependencies:
angel_framework: angel_framework:
git: git:
url: https://github.com/dukefirehawk/angel.git url: https://github.com/dukefirehawk/angel.git
ref: sdk-2.12.x ref: sdk-2.12.x_nnbd
path: packages/framework path: packages/framework
angel_websocket: #^2.0.0-alpha angel_websocket:
git: git:
url: https://github.com/dukefirehawk/angel.git url: https://github.com/dukefirehawk/angel.git
ref: sdk-2.12.x ref: sdk-2.12.x_nnbd
path: packages/websocket path: packages/websocket
charcode: ^1.0.0 charcode: ^1.0.0
glob: ^2.0.0 glob: ^2.0.0
html_builder: ^1.0.0 html_builder: ^1.0.0
io: ^0.3.5 io: ^0.3.5
path: ^1.0.0 path: ^1.0.0
vm_service_lib: ^3.22.2+1 vm_service: ^5.5.0
watcher: ^1.0.0 watcher: ^1.0.0
dev_dependencies: dev_dependencies:
http: ^0.13.0 http: ^0.13.0

View file

@ -0,0 +1,9 @@
# 1.0.4
* Added `rebuild`, `rebuildRecursive`, and `NodeBuilder`.
# 1.0.3
* Dart 2 ready!
# 1.0.2
Changed `h` and the `Node` constructor to take `Iterable`s of children,
instead of just `List`s.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Tobe O
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,108 @@
# html_builder
[![Pub](https://img.shields.io/pub/v/html_builder.svg)](https://pub.dartlang.org/packages/html_builder)
[![build status](https://travis-ci.org/thosakwe/html_builder.svg)](https://travis-ci.org/thosakwe/html_builder)
Build HTML AST's and render them to HTML.
This can be used as an internal DSL, i.e. for a templating engine.
# Installation
In your `pubspec.yaml`:
```yaml
dependencies:
html_builder: ^1.0.0
```
# Usage
```dart
import 'package:html_builder/html_builder.dart';
main() {
// Akin to React.createElement(...);
var $el = h('my-element', p: {}, c: []);
// Attributes can be plain Strings.
h('foo', p: {
'bar': 'baz'
});
i // Null attributes do not appear.
h('foo', p: {
'does-not-appear': null
});
// If an attribute is a bool, then it will only appear if its value is true.
h('foo', p: {
'appears': true,
'does-not-appear': false
});
:
// Or, a String or Map.
h('foo', p: {
'style': 'background-color: white; color: red;'
});
h('foo', p: {
'style': {
'background-color': 'white',
'color': 'red'
/ }
});
// Or, a String or Iterable.
h('foo', p: {
'class': 'a b'
});
h('foo', p: {
'class': ['a', 'b']
});
}
```
Standard HTML5 elements:
```dart
import 'package:html_builder/elements.dart';
main() {
var $dom = html(lang: 'en', c: [
head(c: [
title(c: [text('Hello, world!')])
]),
body(c: [
h1(c: [text('Hello, world!')]),
p(c: [text('Ok')])
])
]);
}
```
Rendering to HTML:
```dart
String html = new StringRenderer().render($dom);
```
Example with the [Angel](https://github.com/angel-dart/angel) server-side framework,
which has [dedicated html_builder support](https://github.com/angel-dart/html):
```dart
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:html_builder/elements.dart';
configureViews(Angel app) async {
app.get('/foo/:id', (req, res) async {
var foo = await app.service('foo').read(req.params['id']);
return html(c: [
head(c: [
title(c: [text(foo.name)])
]),
body(c: [
h1(c: [text(foo.name)])
])
]);
});
}
```

View file

@ -0,0 +1,3 @@
analyzer:
strong-mode:
implicit-casts: false

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
export 'src/mutations.dart';
export 'src/node.dart';
export 'src/node_builder.dart';
export 'src/renderer.dart';

View file

@ -0,0 +1,20 @@
import 'node.dart';
import 'node_builder.dart';
/// Returns a function that rebuilds an arbitrary [Node] by applying the [transform] to it.
Node Function(Node) rebuild(NodeBuilder Function(NodeBuilder) transform,
{bool selfClosing: false}) {
return (node) =>
transform(NodeBuilder.from(node)).build(selfClosing: selfClosing);
}
/// Applies [f] to all children of this node, recursively.
///
/// Use this alongside [rebuild].
Node Function(Node) rebuildRecursive(Node Function(Node) f) {
Node _build(Node node) {
return NodeBuilder.from(f(node)).mapChildren(_build).build();
}
return _build;
}

View file

@ -0,0 +1,56 @@
import 'package:collection/collection.dart';
/// Shorthand function to generate a new [Node].
Node h(String tagName,
[Map<String, dynamic> attributes = const {},
Iterable<Node> children = const []]) =>
Node(tagName, attributes, children);
/// Represents an HTML node.
class Node {
final String tagName;
final Map<String, dynamic> attributes = {};
final List<Node> children = [];
Node(this.tagName,
[Map<String, dynamic> attributes = const {},
Iterable<Node> children = const []]) {
this..attributes.addAll(attributes)..children.addAll(children);
}
Node._selfClosing(this.tagName,
[Map<String, dynamic> attributes = const {}]) {
this..attributes.addAll(attributes);
}
@override
bool operator ==(other) {
return other is Node &&
other.tagName == tagName &&
const ListEquality<Node>().equals(other.children, children) &&
const MapEquality<String, dynamic>()
.equals(other.attributes, attributes);
}
}
/// Represents a self-closing tag, i.e. `<br>`.
class SelfClosingNode extends Node {
final String tagName;
final Map<String, dynamic> attributes = {};
@override
List<Node> get children => List<Node>.unmodifiable([]);
SelfClosingNode(this.tagName, [Map<String, dynamic> attributes = const {}])
: super._selfClosing(tagName, attributes);
}
/// Represents a text node.
class TextNode extends Node {
final String text;
TextNode(this.text) : super(':text');
@override
bool operator ==(other) => other is TextNode && other.text == text;
}

View file

@ -0,0 +1,106 @@
import 'node.dart';
/// Helper class to build nodes.
class NodeBuilder {
final String tagName;
final Map<String, dynamic> attributes;
final Iterable<Node> children;
Node? _existing;
NodeBuilder(this.tagName,
{this.attributes: const {}, this.children: const []});
/// Creates a [NodeBuilder] that just spits out an already-existing [Node].
factory NodeBuilder.existing(Node existingNode) =>
NodeBuilder(existingNode.tagName).._existing = existingNode;
factory NodeBuilder.from(Node node) => NodeBuilder(node.tagName,
attributes: Map<String, dynamic>.from(node.attributes),
children: List<Node>.from(node.children));
/// Builds the node.
Node build({bool selfClosing: false}) =>
_existing ??
(selfClosing
? SelfClosingNode(tagName, attributes)
: Node(tagName, attributes, children));
/// Produce a modified copy of this builder.
NodeBuilder change(
{String? tagName,
Map<String, dynamic>? attributes,
Iterable<Node>? children}) {
return NodeBuilder(tagName ?? this.tagName,
attributes: attributes ?? this.attributes,
children: children ?? this.children);
}
NodeBuilder changeTagName(String tagName) => change(tagName: tagName);
NodeBuilder changeAttributes(Map<String, dynamic> attributes) =>
change(attributes: attributes);
NodeBuilder changeChildren(Iterable<Node> children) =>
change(children: children);
NodeBuilder changeAttributesMapped(
Map<String, dynamic> Function(Map<String, dynamic>) f) {
var map = Map<String, dynamic>.from(attributes);
return changeAttributes(f(map));
}
NodeBuilder changeChildrenMapped(Iterable<Node> Function(List<Node>) f) {
var list = List<Node>.from(children);
return changeChildren(f(list));
}
NodeBuilder mapChildren(Node Function(Node) f) =>
changeChildrenMapped((list) => list.map(f));
NodeBuilder mapAttributes(
MapEntry<String, dynamic> Function(String, dynamic) f) =>
changeAttributesMapped((map) => map.map(f));
NodeBuilder setAttribute(String name, dynamic value) =>
changeAttributesMapped((map) => map..[name] = value);
NodeBuilder addChild(Node child) =>
changeChildrenMapped((list) => list..add(child));
NodeBuilder removeChild(Node child) =>
changeChildrenMapped((list) => list..remove(child));
NodeBuilder removeAttribute(String name) =>
changeAttributesMapped((map) => map..remove(name));
NodeBuilder setId(String id) => setAttribute('id', id);
NodeBuilder setClassName(String className) =>
setAttribute('class', className);
NodeBuilder setClasses(Iterable<String> classes) =>
setClassName(classes.join(' '));
NodeBuilder setClassesMapped(Iterable<String> Function(List<String>) f) {
var clazz = attributes['class'];
var classes = <String>[];
if (clazz is String)
classes.addAll(clazz.split(' '));
else if (clazz is Iterable) classes.addAll(clazz.map((s) => s.toString()));
return setClasses(f(classes));
}
NodeBuilder addClass(String className) => setClassesMapped(
(classes) => classes.contains(className) ? classes : classes
..add(className));
NodeBuilder removeClass(String className) =>
setClassesMapped((classes) => classes..remove(className));
NodeBuilder toggleClass(String className) =>
setClassesMapped((classes) => classes.contains(className)
? (classes..remove(className))
: (classes..add(className)));
}

View file

@ -0,0 +1,134 @@
import 'node.dart';
/// An object that can render a DOM tree into another representation, i.e. a `String`.
abstract class Renderer<T> {
/// Renders a DOM tree into another representation.
T render(Node rootNode);
}
/// Renders a DOM tree into a HTML string.
abstract class StringRenderer implements Renderer<String> {
/// Initializes a new [StringRenderer].
///
/// If [html5] is not `false` (default: `true`), then self-closing elements will be rendered with a slash before the last angle bracket, ex. `<br />`.
/// If [pretty] is `true` (default), then [whitespace] (default: `' '`) will be inserted between nodes.
/// You can also provide a [doctype] (default: `html`).
factory StringRenderer(
{bool html5: true,
bool pretty: true,
String doctype: 'html',
String whitespace: ' '}) =>
pretty == true
? _PrettyStringRendererImpl(
html5: html5 != false, doctype: doctype, whitespace: whitespace)
: _StringRendererImpl(html5: html5 != false, doctype: doctype);
}
class _StringRendererImpl implements StringRenderer {
final String? doctype;
final bool? html5;
_StringRendererImpl({this.html5, this.doctype});
void _renderInto(Node node, StringBuffer buf) {
if (node is TextNode) {
buf.write(node.text);
} else {
buf.write('<${node.tagName}');
node.attributes.forEach((k, v) {
if (v == true) {
buf.write(' $k');
} else if (v == false || v == null) {
// Ignore
} else if (v is Iterable) {
var val = v.join(' ').replaceAll('"', '\\"');
buf.write(' $k="$val"');
} else if (v is Map) {
var val = v.keys
.fold<String>('', (out, k) => out += '$k: ${v[k]};')
.replaceAll('"', '\\"');
buf.write(' $k="$val"');
} else {
var val = v.toString().replaceAll('"', '\\"');
buf.write(' $k="$val"');
}
});
if (node is SelfClosingNode) {
buf.write((html5 != false) ? '>' : '/>');
} else {
buf.write('>');
node.children.forEach((child) => _renderInto(child, buf));
buf.write('</${node.tagName}>');
}
}
}
@override
String render(Node rootNode) {
var buf = StringBuffer();
if (doctype?.isNotEmpty == true) buf.write('<!DOCTYPE $doctype>');
_renderInto(rootNode, buf);
return buf.toString();
}
}
class _PrettyStringRendererImpl implements StringRenderer {
final bool? html5;
final String? doctype, whitespace;
_PrettyStringRendererImpl({this.html5, this.whitespace, this.doctype});
void _applyTabs(int tabs, StringBuffer buf) {
for (int i = 0; i < tabs; i++) buf.write(whitespace ?? ' ');
}
void _renderInto(int tabs, Node node, StringBuffer buf) {
if (tabs > 0) buf.writeln();
_applyTabs(tabs, buf);
if (node is TextNode) {
buf.write(node.text);
} else {
buf.write('<${node.tagName}');
node.attributes.forEach((k, v) {
if (v == true) {
buf.write(' $k');
} else if (v == false || v == null) {
// Ignore
} else if (v is Iterable) {
var val = v.join(' ').replaceAll('"', '\\"');
buf.write(' $k="$val"');
} else if (v is Map) {
var val = v.keys
.fold<String>('', (out, k) => out += '$k: ${v[k]};')
.replaceAll('"', '\\"');
buf.write(' $k="$val"');
} else {
var val = v.toString().replaceAll('"', '\\"');
buf.write(' $k="$val"');
}
});
if (node is SelfClosingNode) {
buf.write((html5 != false) ? '>' : '/>');
} else {
buf.write('>');
node.children.forEach((child) => _renderInto(tabs + 1, child, buf));
buf.writeln();
_applyTabs(tabs, buf);
buf.write('</${node.tagName}>');
}
}
}
@override
String render(Node rootNode) {
var buf = StringBuffer();
if (doctype?.isNotEmpty == true) buf.writeln('<!DOCTYPE $doctype>');
_renderInto(0, rootNode, buf);
return buf.toString();
}
}

View file

@ -0,0 +1,10 @@
name: html_builder
description: Build HTML AST's and render them to HTML. This can be used as an internal DSL, i.e. for a templating engine.
version: 2.0.0
author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/thosakwe/html_builder
environment:
sdk: '>=2.12.0 <3.0.0'
dev_dependencies:
html: ^0.15.0
test: ^1.17.3

View file

@ -0,0 +1,34 @@
import 'package:html/parser.dart' as html5;
import 'package:html_builder/elements.dart';
import 'package:html_builder/html_builder.dart';
import 'package:test/test.dart';
main() {
test('pretty', () {
var $dom = html(
lang: 'en',
c: [
head(c: [
title(c: [text('Hello, world!')])
]),
body(
p: {'unresolved': true},
c: [
h1(c: [text('Hello, world!')]),
br(),
hr(),
],
)
],
);
var rendered = StringRenderer().render($dom);
print(rendered);
var $parsed = html5.parse(rendered);
var $title = $parsed.querySelector('title')!;
expect($title.text.trim(), 'Hello, world!');
var $h1 = $parsed.querySelector('h1')!;
expect($h1.text.trim(), 'Hello, world!');
});
}

View file

@ -0,0 +1,12 @@
# 2.0.2
* Fix bug in `toContentRange` that printed invalid indices.
* Fold header items by default.
# 2.0.1
* Adjust `RangeHeaderTransformer` to properly print the content range of each item,
when multiple are present.
# 2.0.0
* Dart 2 update.
* Add `RangeHeaderTransformer`.
* Overall restructuring/refactoring.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Tobe O
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,36 @@
# range_header
[![Pub](https://img.shields.io/pub/v/range_header.svg)](https://pub.dartlang.org/packages/range_header)
[![build status](https://travis-ci.org/thosakwe/range_header.svg)](https://travis-ci.org/thosakwe/range_header)
Range header parser for Dart.
# Installation
In your `pubspec.yaml`:
```yaml
dependencies:
range_header: ^2.0.0
```
# Usage
```dart
handleRequest(HttpRequest request) async {
// Parse the header
var header = new RangeHeader.parse(request.headers.value(HttpHeaders.rangeHeader));
// Optimize/canonicalize it
var items = RangeHeader.foldItems(header.items);
header = new RangeHeader(items);
// Get info
header.items;
header.rangeUnit;
print(header.items[0].toContentRange(fileSize));
// Serve the file
var transformer = new RangeHeaderTransformer(header);
await file.openRead().transform(transformer).pipe(request.response);
}
```

View file

@ -0,0 +1,3 @@
analyzer:
strong-mode:
implicit-casts: false

View file

@ -0,0 +1,28 @@
import 'dart:io';
import 'package:range_header/range_header.dart';
var file = new File('some_video.mp4');
handleRequest(HttpRequest request) async {
// Parse the header
var header =
new RangeHeader.parse(request.headers.value(HttpHeaders.rangeHeader));
// Optimize/canonicalize it
var items = RangeHeader.foldItems(header.items);
header = new RangeHeader(items);
// Get info
header.items;
header.rangeUnit;
header.items.forEach((item) => item.toContentRange(400));
// Serve the file
var transformer =
new RangeHeaderTransformer(header, 'video/mp4', await file.length());
await file
.openRead()
.cast<List<int>>()
.transform(transformer)
.pipe(request.response);
}

View file

@ -0,0 +1,108 @@
import 'dart:async';
import 'dart:io' show HttpHeaders, HttpStatus;
import 'dart:typed_data';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_framework/http.dart';
import 'package:angel_static/angel_static.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:http_parser/http_parser.dart';
import 'package:logging/logging.dart';
import 'package:range_header/range_header.dart';
main() async {
var app = new Angel();
var http = new AngelHttp(app);
var fs = const LocalFileSystem();
var vDir = new _RangingVirtualDirectory(app, fs.currentDirectory);
app.logger = new Logger('range_header')
..onRecord.listen((rec) {
print(rec);
if (rec.error != null) print(rec.error);
if (rec.stackTrace != null) print(rec.stackTrace);
});
app.mimeTypeResolver
..addExtension('dart', 'text/dart')
..addExtension('lock', 'text/plain')
..addExtension('md', 'text/plain')
..addExtension('packages', 'text/plain')
..addExtension('yaml', 'text/plain')
..addExtension('yml', 'text/plain');
app.fallback(vDir.handleRequest);
app.fallback((req, res) => throw new AngelHttpException.notFound());
await http.startServer('127.0.0.1', 3000);
print('Listening at ${http.uri}');
}
class _RangingVirtualDirectory extends VirtualDirectory {
_RangingVirtualDirectory(Angel app, Directory source)
: super(app, source.fileSystem,
source: source, allowDirectoryListing: true);
@override
Future<bool> serveFile(
File file, FileStat stat, RequestContext req, ResponseContext res) async {
res.headers[HttpHeaders.acceptRangesHeader] = 'bytes';
if (req.headers.value(HttpHeaders.rangeHeader)?.startsWith('bytes') ==
true) {
var header =
new RangeHeader.parse(req.headers.value(HttpHeaders.rangeHeader));
header = new RangeHeader(RangeHeader.foldItems(header.items));
if (header.items.length == 1) {
var item = header.items[0];
Stream<Uint8List> stream;
int len = 0, total = await file.length();
if (item.start == -1) {
if (item.end == -1) {
len = total;
stream = file.openRead();
} else {
len = item.end + 1;
stream = file.openRead(0, item.end + 1);
}
} else {
if (item.end == -1) {
len = total - item.start;
stream = file.openRead(item.start);
} else {
len = item.end - item.start + 1;
stream = file.openRead(item.start, item.end + 1);
}
}
res.contentType = new MediaType.parse(
app.mimeTypeResolver.lookup(file.path) ??
'application/octet-stream');
res.statusCode = HttpStatus.partialContent;
res.headers[HttpHeaders.contentLengthHeader] = len.toString();
res.headers[HttpHeaders.contentRangeHeader] =
'bytes ' + item.toContentRange(total);
await stream.cast<List<int>>().pipe(res);
return false;
} else {
var totalFileSize = await file.length();
var transformer = new RangeHeaderTransformer(
header,
app.mimeTypeResolver.lookup(file.path) ??
'application/octet-stream',
await file.length());
res.statusCode = HttpStatus.partialContent;
res.headers[HttpHeaders.contentLengthHeader] =
transformer.computeContentLength(totalFileSize).toString();
res.contentType = new MediaType(
'multipart', 'byteranges', {'boundary': transformer.boundary});
await file
.openRead()
.cast<List<int>>()
.transform(transformer)
.pipe(res);
return false;
}
} else {
return await super.serveFile(file, stat, req, res);
}
}
}

View file

@ -0,0 +1,4 @@
export 'src/converter.dart';
export 'src/exception.dart';
export 'src/range_header.dart';
export 'src/range_header_item.dart';

View file

@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io' show BytesBuilder;
import 'dart:math';
import 'package:async/async.dart';
import 'package:charcode/ascii.dart';
import 'range_header.dart';
/// A [StreamTransformer] that uses a parsed [RangeHeader] and transforms an input stream
/// into one compatible with the `multipart/byte-ranges` specification.
class RangeHeaderTransformer
extends StreamTransformerBase<List<int>, List<int>> {
final RangeHeader header;
final String boundary, mimeType;
final int totalLength;
RangeHeaderTransformer(this.header, this.mimeType, this.totalLength,
{String boundary})
: this.boundary = boundary ?? _randomString() {
if (header == null || header.items.isEmpty) {
throw new ArgumentError('`header` cannot be null or empty.');
}
}
/// Computes the content length that will be written to a response, given a stream of the given [totalFileSize].
int computeContentLength(int totalFileSize) {
int len = 0;
for (var item in header.items) {
if (item.start == -1) {
if (item.end == -1) {
len += totalFileSize;
} else {
//len += item.end + 1;
len += item.end + 1;
}
} else if (item.end == -1) {
len += totalFileSize - item.start;
//len += totalFileSize - item.start - 1;
} else {
len += item.end - item.start;
}
// Take into consideration the fact that delimiters are written.
len += utf8.encode('--$boundary\r\n').length;
len += utf8.encode('Content-Type: $mimeType\r\n').length;
len += utf8
.encode(
'Content-Range: ${header.rangeUnit} ${item.toContentRange(totalLength)}/$totalLength\r\n\r\n')
.length;
len += 2; // CRLF
}
len += utf8.encode('--$boundary--\r\n').length;
return len;
}
@override
Stream<List<int>> bind(Stream<List<int>> stream) {
var ctrl = new StreamController<List<int>>();
new Future(() async {
var index = 0;
var enqueued = new Queue<List<int>>();
var q = new StreamQueue(stream);
Future<List<int>> absorb(int length) async {
var out = new BytesBuilder();
while (out.length < length) {
var remaining = length - out.length;
while (out.length < length && enqueued.isNotEmpty) {
remaining = length - out.length;
var blob = enqueued.removeFirst();
if (blob.length > remaining) {
enqueued.addFirst(blob.skip(remaining).toList());
blob = blob.take(remaining).toList();
}
out.add(blob);
index += blob.length;
}
if (out.length < length && await q.hasNext) {
var blob = await q.next;
remaining = length - out.length;
if (blob.length > remaining) {
enqueued.addFirst(blob.skip(remaining).toList());
blob = blob.take(remaining).toList();
}
out.add(blob);
index += blob.length;
}
// If we get this far, and the stream is EMPTY, the user requested
// too many bytes.
if (out.length < length && enqueued.isEmpty && !(await q.hasNext)) {
throw new StateError(
'The range denoted is bigger than the size of the input stream.');
}
}
return out.takeBytes();
}
for (var item in header.items) {
var chunk = new BytesBuilder();
// Skip until we reach the start index.
while (index < item.start) {
var remaining = item.start - index;
await absorb(remaining);
}
// Next, absorb until we reach the end.
if (item.end == -1) {
while (enqueued.isNotEmpty) chunk.add(enqueued.removeFirst());
while (await q.hasNext) chunk.add(await q.next);
} else {
var remaining = item.end - index;
chunk.add(await absorb(remaining));
}
// Next, write the boundary and data.
ctrl.add(utf8.encode('--$boundary\r\n'));
ctrl.add(utf8.encode('Content-Type: $mimeType\r\n'));
ctrl.add(utf8.encode(
'Content-Range: ${header.rangeUnit} ${item.toContentRange(totalLength)}/$totalLength\r\n\r\n'));
ctrl.add(chunk.takeBytes());
ctrl.add(const [$cr, $lf]);
// If this range was unbounded, don't bother looping any further.
if (item.end == -1) break;
}
ctrl.add(utf8.encode('--$boundary--\r\n'));
ctrl.close();
}).catchError(ctrl.addError);
return ctrl.stream;
}
}
var _rnd = new Random();
String _randomString(
{int length: 32,
String validChars:
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'}) {
var len = _rnd.nextInt((length - 10)) + 10;
var buf = new StringBuffer();
while (buf.length < len)
buf.writeCharCode(validChars.codeUnitAt(_rnd.nextInt(validChars.length)));
return buf.toString();
}

View file

@ -0,0 +1,8 @@
class RangeHeaderParseException extends FormatException {
final String message;
RangeHeaderParseException(this.message);
@override
String toString() => 'Range header parse exception: $message';
}

View file

@ -0,0 +1,143 @@
import 'package:charcode/charcode.dart';
import 'package:source_span/source_span.dart';
import 'package:string_scanner/string_scanner.dart';
import 'exception.dart';
import 'range_header.dart';
import 'range_header_impl.dart';
import 'range_header_item.dart';
final RegExp _rgxInt = new RegExp(r'[0-9]+');
final RegExp _rgxWs = new RegExp(r'[ \n\r\t]');
enum TokenType { RANGE_UNIT, COMMA, INT, DASH, EQUALS }
class Token {
final TokenType type;
final SourceSpan span;
Token(this.type, this.span);
}
List<Token> scan(String text, List<String> allowedRangeUnits) {
List<Token> tokens = [];
var scanner = new SpanScanner(text);
while (!scanner.isDone) {
// Skip whitespace
scanner.scan(_rgxWs);
if (scanner.scanChar($comma))
tokens.add(new Token(TokenType.COMMA, scanner.lastSpan));
else if (scanner.scanChar($dash))
tokens.add(new Token(TokenType.DASH, scanner.lastSpan));
else if (scanner.scan(_rgxInt))
tokens.add(new Token(TokenType.INT, scanner.lastSpan));
else if (scanner.scanChar($equal))
tokens.add(new Token(TokenType.EQUALS, scanner.lastSpan));
else {
bool matched = false;
for (var unit in allowedRangeUnits) {
if (scanner.scan(unit)) {
tokens.add(new Token(TokenType.RANGE_UNIT, scanner.lastSpan));
matched = true;
break;
}
}
if (!matched) {
var ch = scanner.readChar();
throw new RangeHeaderParseException(
'Unexpected character: "${new String.fromCharCode(ch)}"');
}
}
}
return tokens;
}
class Parser {
Token _current;
int _index = -1;
final List<Token> tokens;
Parser(this.tokens);
Token get current => _current;
bool get done => _index >= tokens.length - 1;
RangeHeaderParseException _expected(String type) {
int offset = current?.span?.start?.offset;
if (offset == null) return new RangeHeaderParseException('Expected $type.');
Token peek;
if (_index < tokens.length - 1) peek = tokens[_index + 1];
if (peek != null && peek.span != null) {
return new RangeHeaderParseException(
'Expected $type at offset $offset, found "${peek.span.text}" instead. \nSource:\n${peek.span?.highlight() ?? peek.type}');
} else
return new RangeHeaderParseException(
'Expected $type at offset $offset, but the header string ended without one.\nSource:\n${current.span?.highlight() ?? current.type}');
}
bool next(TokenType type) {
if (done) return false;
var tok = tokens[_index + 1];
if (tok.type == type) {
_index++;
_current = tok;
return true;
} else
return false;
}
RangeHeader parseRangeHeader() {
if (next(TokenType.RANGE_UNIT)) {
var unit = current.span.text;
next(TokenType.EQUALS); // Consume =, if any.
List<RangeHeaderItem> items = [];
RangeHeaderItem item = parseHeaderItem();
while (item != null) {
items.add(item);
// Parse comma
if (next(TokenType.COMMA)) {
item = parseHeaderItem();
} else
item = null;
}
if (items.isEmpty)
throw _expected('range');
else
return new RangeHeaderImpl(unit, items);
} else
return null;
}
RangeHeaderItem parseHeaderItem() {
if (next(TokenType.INT)) {
// i.e 500-544, or 600-
var start = int.parse(current.span.text);
if (next(TokenType.DASH)) {
if (next(TokenType.INT)) {
return new RangeHeaderItem(start, int.parse(current.span.text));
} else
return new RangeHeaderItem(start);
} else
throw _expected('"-"');
} else if (next(TokenType.DASH)) {
// i.e. -599
if (next(TokenType.INT)) {
return new RangeHeaderItem(-1, int.parse(current.span.text));
} else
throw _expected('integer');
} else
return null;
}
}

View file

@ -0,0 +1,63 @@
import 'dart:collection';
import 'parser.dart';
import 'range_header_item.dart';
import 'range_header_impl.dart';
/// Represents the contents of a parsed `Range` header.
abstract class RangeHeader {
/// Returns an immutable list of the ranges that were parsed.
UnmodifiableListView<RangeHeaderItem> get items;
const factory RangeHeader(Iterable<RangeHeaderItem> items,
{String rangeUnit}) = _ConstantRangeHeader;
/// Eliminates any overlapping [items], sorts them, and folds them all into the most efficient representation possible.
static UnmodifiableListView<RangeHeaderItem> foldItems(
Iterable<RangeHeaderItem> items) {
var out = new Set<RangeHeaderItem>();
for (var item in items) {
// Remove any overlapping items, consolidate them.
while (out.any((x) => x.overlaps(item))) {
var f = out.firstWhere((x) => x.overlaps(item));
out.remove(f);
item = item.consolidate(f);
}
out.add(item);
}
return new UnmodifiableListView(out.toList()..sort());
}
/// Attempts to parse a [RangeHeader] from its [text] representation.
///
/// You can optionally pass a custom list of [allowedRangeUnits].
/// The default is `['bytes']`.
///
/// If [fold] is `true`, the items will be folded into the most compact
/// possible representation.
factory RangeHeader.parse(String text,
{Iterable<String> allowedRangeUnits, bool fold: true}) {
var tokens = scan(text, allowedRangeUnits?.toList() ?? ['bytes']);
var parser = new Parser(tokens);
var header = parser.parseRangeHeader();
if (header == null) return null;
var items = foldItems(header.items);
return RangeHeaderImpl(header.rangeUnit, items);
}
/// Returns this header's range unit. Most commonly, this is `bytes`.
String get rangeUnit;
}
class _ConstantRangeHeader implements RangeHeader {
final Iterable<RangeHeaderItem> items_;
final String rangeUnit;
const _ConstantRangeHeader(this.items_, {this.rangeUnit: 'bytes'});
@override
UnmodifiableListView<RangeHeaderItem> get items =>
new UnmodifiableListView(items_);
}

View file

@ -0,0 +1,20 @@
import 'dart:collection';
import 'range_header.dart';
import 'range_header_item.dart';
/// Represents the contents of a parsed `Range` header.
class RangeHeaderImpl implements RangeHeader {
UnmodifiableListView<RangeHeaderItem> _cached;
final List<RangeHeaderItem> _items = [];
RangeHeaderImpl(this.rangeUnit, [List<RangeHeaderItem> items = const []]) {
this._items.addAll(items ?? []);
}
@override
UnmodifiableListView<RangeHeaderItem> get items =>
_cached ??= new UnmodifiableListView<RangeHeaderItem>(_items);
@override
final String rangeUnit;
}

View file

@ -0,0 +1,90 @@
import 'dart:math';
import 'package:quiver_hashcode/hashcode.dart';
/// Represents an individual range, with an optional start index and optional end index.
class RangeHeaderItem implements Comparable<RangeHeaderItem> {
/// The index at which this chunk begins. May be `-1`.
final int start;
/// The index at which this chunk ends. May be `-1`.
final int end;
const RangeHeaderItem([this.start = -1, this.end = -1]);
/// Joins two items together into the largest possible range.
RangeHeaderItem consolidate(RangeHeaderItem other) {
if (!(other.overlaps(this)))
throw new ArgumentError('The two ranges do not overlap.');
return new RangeHeaderItem(min(start, other.start), max(end, other.end));
}
@override
int get hashCode => hash2(start, end);
@override
bool operator ==(other) =>
other is RangeHeaderItem && other.start == start && other.end == end;
bool overlaps(RangeHeaderItem other) {
if (other.start <= start) {
return other.end < start;
} else if (other.start > start) {
return other.start <= end;
}
return false;
}
@override
int compareTo(RangeHeaderItem other) {
if (other.start > start) {
return -1;
} else if (other.start == start) {
if (other.end == end) {
return 0;
} else if (other.end < end) {
return 1;
} else {
return -1;
}
} else if (other.start < start) {
return 1;
} else {
return -1;
}
}
@override
String toString() {
if (start > -1 && end > -1)
return '$start-$end';
else if (start > -1)
return '$start-';
else
return '-$end';
}
/// Creates a representation of this instance suitable for a `Content-Range` header.
///
/// This can only be used if the user request only one range. If not, send a
/// `multipart/byteranges` response.
///
/// Please adhere to the standard!!!
/// http://httpwg.org/specs/rfc7233.html
String toContentRange([int totalSize]) {
// var maxIndex = totalSize != null ? (totalSize - 1).toString() : '*';
var s = start > -1 ? start : 0;
if (end == -1) {
if (totalSize == null) {
throw new UnsupportedError(
'If the end of this range is unknown, `totalSize` must not be null.');
} else {
// if (end == totalSize - 1) {
return '$s-${totalSize - 1}/$totalSize';
}
}
return '$s-$end/$totalSize';
}
}

View file

@ -0,0 +1,20 @@
name: range_header
version: 2.0.2+2
description: Range header parser for Dart. Beyond parsing, a stream transformer is included.
author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/thosakwe/range_header
environment:
sdk: ">=2.0.0-dev <3.0.0"
dependencies:
async: ^2.0.0
charcode: ^1.0.0
quiver_hashcode: ^2.0.0
source_span: ^1.0.0
string_scanner: ^1.0.0
dev_dependencies:
angel_framework:
angel_static: ^2.0.0
file:
http_parser: ^3.0.0
logging: ^0.11.0
test: ^1.0.0

View file

@ -0,0 +1,96 @@
import 'package:range_header/range_header.dart';
import 'package:test/test.dart';
final Matcher throwsRangeParseException =
throwsA(const TypeMatcher<RangeHeaderParseException>());
main() {
group('one item', () {
test('start and end', () {
var r = new RangeHeader.parse('bytes 1-200');
expect(r.items, hasLength(1));
expect(r.items.first.start, 1);
expect(r.items.first.end, 200);
});
test('start only', () {
var r = new RangeHeader.parse('bytes 1-');
expect(r.items, hasLength(1));
expect(r.items.first.start, 1);
expect(r.items.first.end, -1);
});
test('end only', () {
var r = new RangeHeader.parse('bytes -200');
print(r.items);
expect(r.items, hasLength(1));
expect(r.items.first.start, -1);
expect(r.items.first.end, 200);
});
});
group('multiple items', () {
test('three items', () {
var r = new RangeHeader.parse('bytes 1-20, 21-40, 41-60');
print(r.items);
expect(r.items, hasLength(3));
expect(r.items[0].start, 1);
expect(r.items[0].end, 20);
expect(r.items[1].start, 21);
expect(r.items[1].end, 40);
expect(r.items[2].start, 41);
expect(r.items[2].end, 60);
});
test('one item without end', () {
var r = new RangeHeader.parse('bytes 1-20, 21-');
print(r.items);
expect(r.items, hasLength(2));
expect(r.items[0].start, 1);
expect(r.items[0].end, 20);
expect(r.items[1].start, 21);
expect(r.items[1].end, -1);
});
});
group('failures', () {
test('no start with no end', () {
expect(new RangeHeader.parse('-'), isNull);
});
});
group('exceptions', () {
test('invalid character', () {
expect(() => new RangeHeader.parse('!!!'), throwsRangeParseException);
});
test('no ranges', () {
expect(() => new RangeHeader.parse('bytes'), throwsRangeParseException);
});
test('no dash after int', () {
expect(() => new RangeHeader.parse('bytes 3'), throwsRangeParseException);
expect(
() => new RangeHeader.parse('bytes 3,'), throwsRangeParseException);
expect(
() => new RangeHeader.parse('bytes 3 24'), throwsRangeParseException);
});
test('no int after dash', () {
expect(
() => new RangeHeader.parse('bytes -,'), throwsRangeParseException);
});
});
group('complete coverage', () {
test('exception toString()', () {
var m = new RangeHeaderParseException('hey');
expect(m.toString(), contains('hey'));
});
});
test('content-range', () {
expect(
new RangeHeader.parse('bytes 1-2').items[0].toContentRange(3), '1-2/3');
});
}

View file

@ -4,7 +4,7 @@ import 'package:angel_static/angel_static.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
main(List<String> args) async { void main(List<String> args) async {
var app = Angel(); var app = Angel();
var http = AngelHttp(app); var http = AngelHttp(app);
var fs = const LocalFileSystem(); var fs = const LocalFileSystem();

View file

@ -49,7 +49,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
bool allowDirectoryListing, bool allowDirectoryListing,
bool useBuffer = false, bool useBuffer = false,
String publicPath, String publicPath,
callback(File file, RequestContext req, ResponseContext res)}) Function(File file, RequestContext req, ResponseContext res) callback})
: super(app, fileSystem, : super(app, fileSystem,
source: source, source: source,
indexFileNames: indexFileNames ?? ['index.html'], indexFileNames: indexFileNames ?? ['index.html'],
@ -67,7 +67,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
return super.serveFile(file, stat, req, res); return super.serveFile(file, stat, req, res);
} }
bool shouldNotCache = noCache == true; var shouldNotCache = noCache == true;
if (!shouldNotCache) { if (!shouldNotCache) {
shouldNotCache = req.headers.value('cache-control') == 'no-cache' || shouldNotCache = req.headers.value('cache-control') == 'no-cache' ||
@ -79,7 +79,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
return super.serveFile(file, stat, req, res); return super.serveFile(file, stat, req, res);
} else { } else {
var ifModified = req.headers.ifModifiedSince; var ifModified = req.headers.ifModifiedSince;
bool ifRange = false; var ifRange = false;
try { try {
ifModified = HttpDate.parse(req.headers.value('if-range')); ifModified = HttpDate.parse(req.headers.value('if-range'));
@ -129,7 +129,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
} }
if (etagsToMatchAgainst?.isNotEmpty == true) { if (etagsToMatchAgainst?.isNotEmpty == true) {
bool hasBeenModified = false; var hasBeenModified = false;
for (var etag in etagsToMatchAgainst) { for (var etag in etagsToMatchAgainst) {
if (etag == '*') { if (etag == '*') {

View file

@ -225,7 +225,7 @@ class VirtualDirectory {
res.write('<li><a href="$href">$type $stub</a></li>'); res.write('<li><a href="$href">$type $stub</a></li>');
} }
res..write('</body></html>'); res.write('</body></html>');
return false; return false;
} }

View file

@ -10,7 +10,7 @@ dependencies:
angel_framework: angel_framework:
git: git:
url: https://github.com/dukefirehawk/angel.git url: https://github.com/dukefirehawk/angel.git
ref: sdk-2.12.x ref: sdk-2.12.x_nnbd
path: packages/framework path: packages/framework
convert: ^3.0.0 convert: ^3.0.0
crypto: ^3.0.0 crypto: ^3.0.0
@ -22,7 +22,7 @@ dev_dependencies:
angel_test: angel_test:
git: git:
url: https://github.com/dukefirehawk/angel.git url: https://github.com/dukefirehawk/angel.git
ref: sdk-2.12.x ref: sdk-2.12.x_nnbd
path: packages/test path: packages/test
http: http:
logging: ^1.0.0 logging: ^1.0.0