Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
## 4.2.0-wip
## 4.2.0

- Added support for `https`.
- Add clickable link to serve output.
- Update minimum Dart SDK to `3.10.0`.
- Support multiple `--headers` flags and more robust header value parsing.
For example: `--headers="header1=value1;header2=value2"` or
`--headers="header1=value1" --headers="header2=value2"`
- Add `--list-files` flag to show a directory listing when no `index.html` is
present.
- Add clickable link to serve output.
- Require `sdk: ^3.10.0`.

## 4.1.0

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ See the Dart documentation of [SecurityContext.usePrivateKey](https://api.dart.d

```console
$ dhttpd --help
-p, --port=<port> The port to listen on.
-p, --port=<port> The port to listen on. Provide `0` to use a random port.
(defaults to "8080")
--path=<path> The path to serve. If not set, the current directory is used.
--headers=<headers> HTTP headers to apply to each response. Can be used multiple times. Format: header=value;header2=value
Expand All @@ -64,6 +64,7 @@ $ dhttpd --help
--sslkey=<sslkey> The key of the SSL certificate to use. Also requires sslcert
--sslkeypassword=<sslkeypassword> The password for the key of the SSL certificate to use.
-h, --help Displays the help.
-l, --list-files List the files in the directory if no index.html is present.
```

[path]: https://dart.dev/tools/pub/cmd/pub-global#running-a-script-from-your-path
38 changes: 4 additions & 34 deletions bin/dhttpd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import 'dart:io';

import 'package:dhttpd/dhttpd.dart';
import 'package:dhttpd/src/options.dart';
import 'package:dhttpd/src/utils.dart';

Future<void> main(List<String> args) async {
final Options options;
final Map<String, String> headers;
try {
options = parseOptions(args);
headers = _parseKeyValuePairs(options.headers);
headers = parseKeyValuePairs(options.headers);
} on FormatException catch (e) {
stderr.writeln(e.message);
print(usage);
Expand All @@ -29,39 +30,8 @@ Future<void> main(List<String> args) async {
sslCert: options.sslcert,
sslKey: options.sslkey,
sslPassword: options.sslkeypassword,
listFiles: options.listFiles,
);

print('Server started at ${httpd.urlBase}.');
}

Map<String, String> _parseKeyValuePairs(List<String> headerStrings) {
final headers = <String, String>{};
for (var headerString in headerStrings) {
for (var pair in headerString.split(';')) {
final trimmedPair = pair.trim();
if (trimmedPair.isEmpty) {
continue;
}

final index = trimmedPair.indexOf('=');
if (index == -1) {
throw FormatException(
'Invalid header segment: "$trimmedPair". Expected "key=value".\n'
'For values with semicolons, use a separate --headers flag '
'per header.',
);
}

final key = trimmedPair.substring(0, index).trim();
if (key.isEmpty) {
throw FormatException(
'Invalid header: "$trimmedPair". Key cannot be empty.',
);
}

final value = trimmedPair.substring(index + 1).trim();
headers[key] = value;
}
}
return headers;
print('Serving ${httpd.path} at ${httpd.urlBase}');
}
11 changes: 9 additions & 2 deletions lib/dhttpd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:shelf_static/shelf_static.dart';

import 'src/options.dart';

class Dhttpd {
final class Dhttpd {
final HttpServer _server;
final String path;
final SecurityContext? _securityContext;
Expand Down Expand Up @@ -41,6 +41,7 @@ class Dhttpd {
String? sslCert,
String? sslKey,
String? sslPassword,
bool listFiles = false,
}) async {
path ??= Directory.current.path;

Expand All @@ -54,7 +55,13 @@ class Dhttpd {
final pipeline = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(_headersMiddleware(headers))
.addHandler(createStaticHandler(path, defaultDocument: 'index.html'));
.addHandler(
createStaticHandler(
path,
defaultDocument: 'index.html',
listDirectories: listFiles,
),
);

final server = await io.serve(
pipeline,
Expand Down
10 changes: 9 additions & 1 deletion lib/src/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Options {
abbr: 'p',
valueHelp: 'port',
defaultsTo: defaultPort,
help: 'The port to listen on.',
help: 'The port to listen on. Provide `0` to use a random port.',
)
final int port;

Expand Down Expand Up @@ -62,6 +62,13 @@ class Options {
@CliOption(abbr: 'h', negatable: false, help: 'Displays the help.')
final bool help;

@CliOption(
abbr: 'l',
negatable: false,
help: 'List the files in the directory if no index.html is present.',
)
final bool listFiles;

Options({
required this.port,
this.path,
Expand All @@ -71,5 +78,6 @@ class Options {
this.sslkey,
this.sslkeypassword,
required this.help,
this.listFiles = false,
});
}
11 changes: 9 additions & 2 deletions lib/src/options.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/// Parses string header segments like `Accept=text/html;X-Custom=Value` into a map.
Map<String, String> parseKeyValuePairs(List<String> headerStrings) {
final headers = <String, String>{};
for (var headerString in headerStrings) {
for (var pair in headerString.split(';')) {
final trimmedPair = pair.trim();
if (trimmedPair.isEmpty) {
continue;
}

final index = trimmedPair.indexOf('=');
if (index == -1) {
throw FormatException('''
Invalid header segment: "$trimmedPair". Expected "key=value".
For values with semicolons, use a separate --headers flag per header.''');
}

final key = trimmedPair.substring(0, index).trim();
if (key.isEmpty) {
throw FormatException(
'Invalid header: "$trimmedPair". Key cannot be empty.',
);
}

final value = trimmedPair.substring(index + 1).trim();
headers[key] = value;
}
}
return headers;
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: dhttpd
version: 4.2.0-wip
version: 4.2.0

description: A static HTTP file server for easy local hosting of a directory.
repository: https://github.com/kevmoo/dhttpd
Expand Down
22 changes: 14 additions & 8 deletions test/command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:test_process/test_process.dart';

void main() {
test('--help', () => _readmeCheck(['--help']));
test('--port=8000', _outputCheck);
test('custom headers', _headersCheck);
test('invalid headers', _invalidHeadersCheck);
test('prints help', () => _readmeCheck(['--help']));
test('serves on specified port', _outputCheck);
test('handles custom headers', _headersCheck);
test('rejects invalid headers', _invalidHeadersCheck);
}

Future<void> _readmeCheck(List<String> args) async {
Expand All @@ -23,13 +23,18 @@ Future<void> _readmeCheck(List<String> args) async {
final readme = File('README.md');

final command = ['dhttpd', ...args].join(' ');
final expected = '```console\n\$ $command\n$output\n```';
final expected =
'''
```console
\$ $command
$output
```''';

printOnFailure(expected);

expect(expected, r'''```console
$ dhttpd --help
-p, --port=<port> The port to listen on.
-p, --port=<port> The port to listen on. Provide `0` to use a random port.
(defaults to "8080")
--path=<path> The path to serve. If not set, the current directory is used.
--headers=<headers> HTTP headers to apply to each response. Can be used multiple times. Format: header=value;header2=value
Expand All @@ -39,6 +44,7 @@ $ dhttpd --help
--sslkey=<sslkey> The key of the SSL certificate to use. Also requires sslcert
--sslkeypassword=<sslkeypassword> The password for the key of the SSL certificate to use.
-h, --help Displays the help.
-l, --list-files List the files in the directory if no index.html is present.
```''');

expect(readme.readAsStringSync(), contains(expected));
Expand All @@ -61,7 +67,7 @@ Future<void> _headersCheck() async {
' X-Spaced-Key = spaced-value ',
]);
final line = await process.stdout.next;
expect(line, 'Server started at http://localhost:8001.');
expect(line, 'Serving ${d.sandbox} at http://localhost:8001');

final response = await http.get(Uri.parse('http://localhost:8001'));
expect(response.statusCode, 200);
Expand Down Expand Up @@ -96,7 +102,7 @@ Future<void> _outputCheck() async {

final process = await _runApp(['--port=8000', '--path', d.sandbox]);
final line = await process.stdout.next;
expect(line, 'Server started at http://localhost:8000.');
expect(line, 'Serving ${d.sandbox} at http://localhost:8000');

final response = await http.get(Uri.parse('http://localhost:8000'));
expect(response.statusCode, 200);
Expand Down
15 changes: 15 additions & 0 deletions test/dhttpd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ void main() {
expect(response.headers['x-test-header'], 'TestValue');
});

test('list files', () async {
await d.dir('somelistdir', [d.file('file.txt', 'Content')]).create();

server = await Dhttpd.start(
path: '${d.sandbox}/somelistdir',
port: 0,
listFiles: true,
);

final response = await http.get(Uri.parse(server.urlBase));

expect(response.statusCode, HttpStatus.ok);
expect(response.body, contains('file.txt'));
});

test('404 handling', () async {
server = await Dhttpd.start(path: d.sandbox, port: 0);

Expand Down
79 changes: 79 additions & 0 deletions test/utils_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'package:dhttpd/src/utils.dart';
import 'package:test/test.dart';

void main() {
group('parseKeyValuePairs', () {
test('parses a single key-value pair', () {
expect(parseKeyValuePairs(['Key=Value']), equals({'Key': 'Value'}));
});

test('parses multiple pairs separated by semicolons', () {
expect(
parseKeyValuePairs(['Key1=Value1;Key2=Value2']),
equals({'Key1': 'Value1', 'Key2': 'Value2'}),
);
});

test('parses multiple header strings', () {
expect(
parseKeyValuePairs(['Key1=Value1', 'Key2=Value2']),
equals({'Key1': 'Value1', 'Key2': 'Value2'}),
);
});

test('trims whitespace around keys and values', () {
expect(
parseKeyValuePairs([' Key1 = Value1 ; Key2=Value2 ']),
equals({'Key1': 'Value1', 'Key2': 'Value2'}),
);
});

test('ignores empty segments', () {
expect(
parseKeyValuePairs(['Key=Value;;;Key2=Value2']),
equals({'Key': 'Value', 'Key2': 'Value2'}),
);
});

test('allows empty values', () {
expect(parseKeyValuePairs(['Key=']), equals({'Key': ''}));
expect(
parseKeyValuePairs(['Key= ; Key2=Value']),
equals({'Key': '', 'Key2': 'Value'}),
);
});

test('handles values with equals signs', () {
expect(
parseKeyValuePairs(['Key=Value=With=Equals']),
equals({'Key': 'Value=With=Equals'}),
);
});

test('throws FormatException if a segment lacks an equals sign', () {
expect(
() => parseKeyValuePairs(['KeyOnly']),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected "key=value"'),
),
),
);
});

test('throws FormatException if key is empty', () {
expect(
() => parseKeyValuePairs(['=Value']),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Key cannot be empty'),
),
),
);
});
});
}
Loading