Skip to content
Open
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

- Added support for `https`.
- Update minimum Dart SDK to `3.2.0`.
- Added `--headersfile` option to parse file-specific response headers from a file.
- Changed `--headers` option to support additional characters.

## 4.1.0

Expand Down Expand Up @@ -30,7 +32,7 @@
## 3.0.0

* Set Dart SDK constraint to '>=2.0.0-dev.48.0 <3.0.0'.
* Removed top-level fields `DEFAULT_PORT` and `DEFAULT_HOST` from library.
* Removed top-level fields `DEFAULT_PORT` and `DEFAULT_HOST` from library.

## 2.0.0

Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ Server HTTPS started on port 8080

See the Dart documentation of [SecurityContext.usePrivateKey](https://api.dart.dev/stable/3.3.3/dart-io/SecurityContext/usePrivateKeyBytes.html) for more details.

### Headers

It is possible to pass custom HTTP headers to the server. For simple use cases, pass headers with the `--headers` option, for example:

```console
$ dhttpd --headers="header=value;header2=value"
```

For more complex scenarios, you can pass a file with HTTP header rules with the `--headersfile` option. The accepted formats are JSON files with the following structure:

```json
"headers": [ {
"source": "**/*.@(eot|otf|ttf|ttc|woff|font.css)",
"headers": [ {
"key": "Access-Control-Allow-Origin",
"value": "*"
} ]
} ]
```

And plain text files with the following structure:

```
# This is a comment
/*
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Opener-Policy: same-origin

/secure/page
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer

/static/*
Access-Control-Allow-Origin: *
X-Robots-Tag: nosnippet
```

## Configure

```console
Expand All @@ -58,6 +96,7 @@ $ dhttpd --help
(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. header=value;header2=value
--headersfile=<headersfile> File with HTTP header rules to apply to each response.
--host=<host> The hostname to listen on.
(defaults to "localhost")
--sslcert=<sslcert> The SSL certificate to use. Also requires sslkey
Expand Down
1 change: 1 addition & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ linter:
rules:
- avoid_private_typedef_functions
- avoid_redundant_argument_values
- avoid_slow_async_io
- avoid_unused_constructor_parameters
- avoid_void_async
- cancel_subscriptions
Expand Down
17 changes: 9 additions & 8 deletions bin/dhttpd.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:io';

import 'package:dhttpd/dhttpd.dart';
import 'package:dhttpd/src/headers.dart';
import 'package:dhttpd/src/headers_parser.dart';
import 'package:dhttpd/src/options.dart';

Future<void> main(List<String> args) async {
Expand All @@ -22,8 +24,7 @@ Future<void> main(List<String> args) async {
final httpd = await Dhttpd.start(
path: options.path,
port: options.port,
headers:
options.headers != null ? _parseKeyValuePairs(options.headers!) : null,
headers: _parseHeaders(options.headers, options.headersfile),
address: options.host,
sslCert: options.sslcert,
sslKey: options.sslkey,
Expand All @@ -33,9 +34,9 @@ Future<void> main(List<String> args) async {
print('Server HTTP${httpd.isSSL ? 'S' : ''} started on port ${options.port}');
}

Map<String, String> _parseKeyValuePairs(String str) => <String, String>{
for (var match in _regex.allMatches(str))
match.group(1)!: match.group(2)!,
};

final _regex = RegExp(r'([\w-]+)=([\w-]+)(;|$)');
HeaderRuleSet _parseHeaders(String? headers, String? headersfile) {
final rules = <HeaderRule>[];
if (headers != null) rules.add(HeadersParser.parseString(headers));
if (headersfile != null) rules.addAll(HeadersParser.parseFile(headersfile));
return HeaderRuleSet(rules);
}
12 changes: 6 additions & 6 deletions lib/dhttpd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_static/shelf_static.dart';

import 'src/headers.dart';
import 'src/options.dart';

class Dhttpd {
Expand Down Expand Up @@ -36,7 +37,7 @@ class Dhttpd {
String? path,
int port = defaultPort,
Object address = defaultHost,
Map<String, String>? headers,
HeaderRuleSet? headers,
String? sslCert,
String? sslKey,
String? sslPassword,
Expand All @@ -63,14 +64,13 @@ class Dhttpd {
Future<void> destroy() => _server.close();
}

Middleware _headersMiddleware(Map<String, String>? headers) =>
Middleware _headersMiddleware(HeaderRuleSet? headers) =>
(Handler innerHandler) => (Request request) async {
final response = await innerHandler(request);
final responseHeaders = Map<String, String>.from(response.headers);
if (headers != null) {
for (var entry in headers.entries) {
responseHeaders[entry.key] = entry.value;
}
final customHeaders = headers?.forFile(request.requestedUri.path);
if (customHeaders != null) {
responseHeaders.addAll(customHeaders.asMap());
}
return response.change(headers: responseHeaders);
};
55 changes: 55 additions & 0 deletions lib/src/headers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:glob/glob.dart';

class HttpHeaders {
final Map<String, String> _headers = {};

/// Stores a new header with the given [name] and [value]. If a header with
/// the same [name] already exists (case-insensitive), the new [value] is
/// concatenated with a comma to the existing header.
void add({required String name, required String value}) {
_headers.update(
name.toLowerCase(),
(existing) => '$existing, $value',
ifAbsent: () => value,
);
}

void addAll(HttpHeaders other) {
for (final entry in other._headers.entries) {
add(name: entry.key, value: entry.value);
}
}

Map<String, String> asMap() => _headers;
}

/// A set of [headers] associated to a URL pattern.
class HeaderRule {
final HttpHeaders headers;
final Glob urlPattern;

HeaderRule({
required this.headers,
String urlPattern = '/**', // Match all URLs by default.
}) : urlPattern = Glob(urlPattern);

/// Checks if a URL request [path] matches the URL pattern.
bool matches(String path) => urlPattern.matches(path);
}

/// A set of [HeaderRule] with different URL patterns and headers.
class HeaderRuleSet {
final List<HeaderRule> rules;

HeaderRuleSet(this.rules);

/// Returns the headers of url patterns that match the [requestUrl]
HttpHeaders forFile(String requestUrl) {
final headers = HttpHeaders();
final matchingRules = rules.where((rule) => rule.matches(requestUrl));
for (final rule in matchingRules) {
headers.addAll(rule.headers);
}
return headers;
}
}
140 changes: 140 additions & 0 deletions lib/src/headers_parser.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'headers.dart';

class HeadersParser {
static HeaderRule parseString(String str) {
final headers = HttpHeaders();
for (final match in _regex.allMatches(str)) {
headers.add(name: match.group(1)!, value: match.group(2)!);
}
return HeaderRule(headers: headers);
}

static List<HeaderRule> parseFile(String file) {
if (!_isFile(file)) throw ArgumentError('$file is not a file');

final ext = p.extension(file);
if (ext == '.json') return _JsonHeadersParser.parse(file);
if (ext == '.txt' || ext == '') return _TxtHeaderParser.parse(file);
throw ArgumentError('Invalid headers file: $file');
}

static bool _isFile(String str) => FileSystemEntity.isFileSync(str);

static final _regex = RegExp(r'([\w-]+)=([^\s;]+)(;|$)');
}

/// Parses headers in JSON format. Each rule is a JSON object with a `source`
/// pattern and a list of `headers`. For example:
/// ```json
/// "headers": [ {
/// "source": "/**",
/// "headers": [ {
/// "key": "Access-Control-Allow-Origin",
/// "value": "*"
/// } ]
/// } ]
/// ```
class _JsonHeadersParser {
static List<HeaderRule> parse(String file) {
final json = jsonDecode(File(file).readAsStringSync());
final jsonHeaders = switch (json) {
{'hosting': {'headers': final List<dynamic> headers}} => headers,
{'headers': final List<dynamic> headers} => headers,
_ => throw FormatException('Invalid JSON headers file: $file')
};

return <HeaderRule>[
for (final jsonRule in jsonHeaders) _parseRule(jsonRule)
];
}

static HeaderRule _parseRule(dynamic jsonRule) => switch (jsonRule) {
{
'source': final String source,
'headers': final List<dynamic> headers,
} =>
HeaderRule(
headers: _parseHeaders(headers),
urlPattern: _processUrlPattern(source),
),
_ => throw FormatException('Invalid headers rule:\n$jsonRule')
};

static HttpHeaders _parseHeaders(List<dynamic> jsonHeaders) {
final headers = HttpHeaders();
for (final header in jsonHeaders) {
switch (header) {
case {'key': final String key, 'value': final String value}:
headers.add(name: key, value: value);
default:
throw FormatException('Invalid header:\n$header');
}
}
return headers;
}

/// Replaces @(option1|option2|...|optionN) constructs with the corresponding
/// syntax of the Glob package: {option1,option2,...,optionN}.
static String _processUrlPattern(String urlPattern) {
final regex = RegExp(r'@\(([^)]+)\)');
return urlPattern.replaceAllMapped(
regex,
(match) => '{${match.group(1)!.split('|').join(',')}}',
);
}
}

/// Parses headers in plain text format. Headers must be specified in blocks,
/// with each block starting with a URL pattern followed by an indented list
/// of headers. For example:
/// ```
/// # This is a comment
/// /secure/page
/// X-Frame-Options: DENY
/// X-Content-Type-Options: nosniff
/// Referrer-Policy: no-referrer
///
/// /static/*
/// Access-Control-Allow-Origin: *
/// X-Robots-Tag: nosnippet
/// ```
class _TxtHeaderParser {
static final _ruleRegex = RegExp(
r'^([^\s#]+)$(?:\r\n|\r|\n)((?:[ \t]+.+$(?:\r\n|\r|\n)?)+)',
multiLine: true,
);
static final _headerRegex = RegExp(
r'^[ \t]+([^#\s][\w-]+): (.+)$',
multiLine: true,
);

static List<HeaderRule> parse(String file) {
final content = File(file).readAsStringSync();
return _parseRules(content);
}

static List<HeaderRule> _parseRules(String str) => [
for (final match in _ruleRegex.allMatches(str))
HeaderRule(
headers: _parseHeaders(match.group(2)!),
urlPattern: _processPattern(match.group(1)!),
),
];

static HttpHeaders _parseHeaders(String str) {
final headers = HttpHeaders();
for (final match in _headerRegex.allMatches(str)) {
headers.add(name: match.group(1)!, value: match.group(2)!);
}
return headers;
}

/// Replaces the wildcard character `*` with `**` to match any number of
/// characters, including '/'. This is the behavior of Cloudflare Pages
/// and Netlify.
static String _processPattern(String pattern) =>
pattern.replaceAll('*', '**');
}
7 changes: 7 additions & 0 deletions lib/src/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ class Options {
'HTTP headers to apply to each response. header=value;header2=value')
final String? headers;

@CliOption(
valueHelp: 'headersfile',
help:
'File with HTTP header rules to apply to each response.')
final String? headersfile;

@CliOption(
defaultsTo: defaultHost,
valueHelp: 'host',
Expand Down Expand Up @@ -56,6 +62,7 @@ class Options {
required this.port,
this.path,
this.headers,
this.headersfile,
required this.host,
this.sslcert,
this.sslkey,
Expand Down
6 changes: 6 additions & 0 deletions lib/src/options.g.dart

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

Loading